Skip to content

Commit 4d737ef

Browse files
1980computerclaude
andcommitted
feat(apollo-react): add IterationNavigatorPill component and Execution Count story
Extracts the Option B iteration navigator from the V2 story proposals into a standalone reusable component. IterationNavigatorPill is a unified segmented pill with an All aggregate toggle, per-iteration status dots, click-to-type jump, and a crosshair shortcut to the first failed iteration. Adapts responsively across three size tiers (full ≥400px, compact 260-399px, minimal <260px) derived from props.width. Adds LoopIterationPillState to LoopNode.types and exports the component from the LoopNode module index. Adds an Execution Count doc-layout story to Components/LoopNode showing all three tiers in a live canvas preview with anatomy cards, breakpoint spec table, and usage code block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 07b7a36 commit 4d737ef

5 files changed

Lines changed: 994 additions & 3 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import { cn } from '@uipath/apollo-wind';
2+
import { useRef, useState } from 'react';
3+
import type { ElementStatusValues } from '../../types/execution';
4+
import { CanvasIcon } from '../../utils/icon-registry';
5+
import type { LoopIterationPillState } from './LoopNode.types';
6+
7+
function stopEvent(e: React.SyntheticEvent) {
8+
e.stopPropagation();
9+
}
10+
11+
export function getIterationStatusColor(status: string | undefined): string {
12+
switch (status) {
13+
case 'Completed':
14+
return '#22c55e';
15+
case 'Failed':
16+
return '#ef4444';
17+
case 'InProgress':
18+
return '#f59e0b';
19+
case 'Paused':
20+
return '#a855f7';
21+
case 'Cancelled':
22+
return '#94a3b8';
23+
default:
24+
return 'currentColor';
25+
}
26+
}
27+
28+
export function IterationNavigatorPill({
29+
state,
30+
size = 'full',
31+
}: {
32+
state: LoopIterationPillState;
33+
size?: 'full' | 'compact' | 'minimal';
34+
}) {
35+
const {
36+
activeIndex,
37+
total,
38+
onActiveIndexChange,
39+
disabled,
40+
isAll,
41+
onAllChange,
42+
iterationStatuses,
43+
overallStatus,
44+
} = state;
45+
46+
const [isEditing, setIsEditing] = useState(false);
47+
const [inputValue, setInputValue] = useState('');
48+
const inputRef = useRef<HTMLInputElement>(null);
49+
50+
const canInteract = !disabled && typeof onActiveIndexChange === 'function';
51+
const visibleIndex = activeIndex + 1;
52+
const clampToRange = (v: number) => Math.max(1, Math.min(total, v));
53+
54+
const currentStatus = iterationStatuses?.get(activeIndex);
55+
const firstFailedIndex = iterationStatuses
56+
? [...iterationStatuses.entries()].find(([, s]) => s === 'Failed')?.[0]
57+
: undefined;
58+
const completedCount = iterationStatuses
59+
? [...iterationStatuses.values()].filter((s) => s === 'Completed').length
60+
: undefined;
61+
const failedCount = iterationStatuses
62+
? [...iterationStatuses.values()].filter((s) => s === 'Failed').length
63+
: 0;
64+
65+
const handlePrev = (e: React.MouseEvent) => {
66+
e.stopPropagation();
67+
if (canInteract && !isAll && activeIndex > 0) onActiveIndexChange?.(activeIndex - 1);
68+
};
69+
70+
const handleNext = (e: React.MouseEvent) => {
71+
e.stopPropagation();
72+
if (canInteract && !isAll && activeIndex < total - 1) onActiveIndexChange?.(activeIndex + 1);
73+
};
74+
75+
const toggleAll = (e: React.MouseEvent) => {
76+
e.stopPropagation();
77+
onAllChange(!isAll);
78+
};
79+
80+
const handleJumpToFailed = (e: React.MouseEvent) => {
81+
e.stopPropagation();
82+
if (firstFailedIndex !== undefined) onActiveIndexChange?.(firstFailedIndex);
83+
};
84+
85+
const startEdit = (e: React.MouseEvent) => {
86+
e.stopPropagation();
87+
if (!canInteract || isAll || isEditing) return;
88+
setInputValue(String(visibleIndex));
89+
setIsEditing(true);
90+
requestAnimationFrame(() => inputRef.current?.select());
91+
};
92+
93+
const commitEdit = () => {
94+
const parsed = parseInt(inputValue, 10);
95+
if (!Number.isNaN(parsed)) onActiveIndexChange?.(clampToRange(parsed) - 1);
96+
setIsEditing(false);
97+
};
98+
99+
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
100+
e.stopPropagation();
101+
if (e.key === 'Enter') commitEdit();
102+
if (e.key === 'Escape') setIsEditing(false);
103+
};
104+
105+
const canGoPrev = canInteract && !isAll && activeIndex > 0;
106+
const canGoNext = canInteract && !isAll && activeIndex < total - 1;
107+
108+
// Minimal tier — read-only count chip
109+
if (size === 'minimal') {
110+
return (
111+
<div
112+
className="nodrag nopan pointer-events-auto flex items-center"
113+
onPointerDown={stopEvent}
114+
onMouseDown={stopEvent}
115+
>
116+
<div className="flex h-6 items-center gap-0.5 rounded-full border border-border bg-surface px-2 text-[11px] font-semibold leading-none shadow-sm">
117+
{isAll ? (
118+
completedCount !== undefined ? (
119+
<>
120+
<span style={{ color: getIterationStatusColor('Completed') }}>
121+
{completedCount}
122+
</span>
123+
{failedCount > 0 && (
124+
<span style={{ color: getIterationStatusColor('Failed') }}>{failedCount}</span>
125+
)}
126+
</>
127+
) : (
128+
<>
129+
<span className="opacity-60">Σ</span>
130+
<span className="ml-0.5">{total}</span>
131+
</>
132+
)
133+
) : (
134+
<>
135+
{currentStatus && (
136+
<span
137+
className="h-1.5 w-1.5 shrink-0 rounded-full"
138+
style={{ backgroundColor: getIterationStatusColor(currentStatus) }}
139+
/>
140+
)}
141+
<span>{visibleIndex}</span>
142+
<span className="px-0.5 opacity-60">/</span>
143+
<span>{total}</span>
144+
</>
145+
)}
146+
</div>
147+
</div>
148+
);
149+
}
150+
151+
// Full and compact tiers — unified segmented pill
152+
return (
153+
<div
154+
className="nodrag nopan pointer-events-auto flex items-center gap-1.5"
155+
onPointerDown={stopEvent}
156+
onMouseDown={stopEvent}
157+
onDoubleClick={stopEvent}
158+
>
159+
{/* Single unified pill */}
160+
<div className="nodrag nopan flex h-6 items-stretch overflow-hidden rounded-full border border-border bg-surface shadow-sm">
161+
{/* Left segment — All toggle */}
162+
<button
163+
type="button"
164+
onClick={toggleAll}
165+
onPointerDown={stopEvent}
166+
onMouseDown={stopEvent}
167+
aria-pressed={isAll}
168+
aria-label="Show aggregate across all iterations"
169+
className={cn(
170+
'nodrag nopan select-none px-2.5 text-[11px] font-semibold leading-none transition-colors',
171+
isAll
172+
? 'bg-foreground-accent/15 text-foreground-accent'
173+
: 'text-foreground hover:bg-surface-overlay'
174+
)}
175+
>
176+
All
177+
</button>
178+
179+
{/* Divider */}
180+
<div className="w-px shrink-0 bg-border" />
181+
182+
{/* Right segment — aggregate or navigation */}
183+
{isAll ? (
184+
<button
185+
type="button"
186+
onClick={toggleAll}
187+
onPointerDown={stopEvent}
188+
onMouseDown={stopEvent}
189+
className="nodrag nopan flex items-center gap-1.5 px-2.5 text-[11px] font-semibold leading-none transition-colors hover:bg-surface-overlay"
190+
aria-label="Return to individual iteration view"
191+
title="Click to return to individual iteration view"
192+
>
193+
{completedCount !== undefined ? (
194+
<>
195+
<span style={{ color: getIterationStatusColor('Completed') }}>
196+
{completedCount}
197+
</span>
198+
{failedCount > 0 && (
199+
<span style={{ color: getIterationStatusColor('Failed') }}>{failedCount}</span>
200+
)}
201+
</>
202+
) : (
203+
<>
204+
<span aria-hidden className="opacity-60">
205+
Σ
206+
</span>
207+
<span>{total}</span>
208+
</>
209+
)}
210+
</button>
211+
) : (
212+
<div className="flex items-stretch">
213+
{/* Prev — hidden in compact */}
214+
{size === 'full' && (
215+
<button
216+
type="button"
217+
className={cn(
218+
'nodrag nopan flex w-5 items-center justify-center text-foreground transition-opacity',
219+
!canGoPrev
220+
? 'cursor-not-allowed opacity-40'
221+
: 'cursor-pointer hover:bg-surface-overlay'
222+
)}
223+
disabled={!canGoPrev}
224+
aria-label="Previous iteration"
225+
onClick={handlePrev}
226+
onPointerDown={stopEvent}
227+
onMouseDown={stopEvent}
228+
>
229+
<CanvasIcon icon="chevron-left" size={12} />
230+
</button>
231+
)}
232+
233+
{/* Editable fraction with status dot */}
234+
<span
235+
className={cn(
236+
'flex min-w-10 select-none items-center justify-center gap-0.5 px-1 text-[11px] font-semibold leading-none',
237+
canInteract && !isEditing && 'cursor-pointer hover:text-foreground-accent'
238+
)}
239+
onClick={startEdit}
240+
title={canInteract ? 'Click to jump to a specific iteration' : undefined}
241+
>
242+
{isEditing ? (
243+
<>
244+
<input
245+
ref={inputRef}
246+
type="number"
247+
min={1}
248+
max={total}
249+
value={inputValue}
250+
onChange={(e) => setInputValue(e.target.value)}
251+
onBlur={commitEdit}
252+
onKeyDown={handleInputKeyDown}
253+
onPointerDown={stopEvent}
254+
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"
255+
/>
256+
<span className="px-0.5 opacity-60">/</span>
257+
<span>{total}</span>
258+
</>
259+
) : (
260+
<>
261+
{currentStatus && (
262+
<span
263+
className="h-1.5 w-1.5 shrink-0 rounded-full"
264+
style={{ backgroundColor: getIterationStatusColor(currentStatus) }}
265+
/>
266+
)}
267+
<span>{visibleIndex}</span>
268+
<span className="px-0.5 opacity-60">/</span>
269+
<span>{total}</span>
270+
</>
271+
)}
272+
</span>
273+
274+
{/* Next — hidden in compact */}
275+
{size === 'full' && (
276+
<button
277+
type="button"
278+
className={cn(
279+
'nodrag nopan flex w-5 items-center justify-center text-foreground transition-opacity',
280+
!canGoNext
281+
? 'cursor-not-allowed opacity-40'
282+
: 'cursor-pointer hover:bg-surface-overlay'
283+
)}
284+
disabled={!canGoNext}
285+
aria-label="Next iteration"
286+
onClick={handleNext}
287+
onPointerDown={stopEvent}
288+
onMouseDown={stopEvent}
289+
>
290+
<CanvasIcon icon="chevron-right" size={12} />
291+
</button>
292+
)}
293+
</div>
294+
)}
295+
</div>
296+
297+
{/* Jump-to-failed shortcut — hidden when loop is globally Failed */}
298+
{firstFailedIndex !== undefined &&
299+
!isAll &&
300+
canInteract &&
301+
overallStatus !== ('Failed' as ElementStatusValues) && (
302+
<button
303+
type="button"
304+
className="nodrag nopan inline-flex h-6 w-6 items-center justify-center rounded-full border border-border bg-surface shadow-sm transition-colors hover:border-red-400"
305+
onClick={handleJumpToFailed}
306+
onPointerDown={stopEvent}
307+
onMouseDown={stopEvent}
308+
aria-label="Jump to first failed iteration"
309+
title={`Jump to iteration ${firstFailedIndex + 1} (failed)`}
310+
>
311+
<CanvasIcon icon="crosshair" size={12} color={getIterationStatusColor('Failed')} />
312+
</button>
313+
)}
314+
</div>
315+
);
316+
}

0 commit comments

Comments
 (0)