Skip to content

Commit 17edda7

Browse files
dbacomputerclaude
andcommitted
feat(storybook): add Action Needed state and LoopNode V2 responsive pickers
BaseNode V2: - Add ActionNeeded execution state with amber circle adornment (top-right) - Add ActionNeededCanvasNode: renders Take Action button below node label - Add dedicated Action Needed story showing all three shapes fit to view - Document Action Needed in anatomy page with design notes and reference table - Fix circular module TDZ: get BaseNode via useNodeTypesFromRegistry hook instead of direct import (BaseNode → adornment-resolver → canvas/index.ts → HierarchicalCanvas DEFAULT_NODE_TYPES access before init) LoopNode V2: - Add responsive tiers (full ≥400px, compact 260-399px, minimal <260px) to IterationNavigatorV2 and IterationNavigatorPill, driven by props.width - Document responsive behavior in anatomy with breakpoint table and interactive tier mocks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d0cdebd commit 17edda7

2 files changed

Lines changed: 548 additions & 43 deletions

File tree

packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx

Lines changed: 293 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Meta, StoryObj } from '@storybook/react';
22
import { Column } from '@uipath/apollo-react/canvas/layouts';
3-
import type { Node } from '@uipath/apollo-react/canvas/xyflow/react';
3+
import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react';
44
import { Panel } from '@uipath/apollo-react/canvas/xyflow/react';
55
import { Button, Input, Label, Slider, Switch } from '@uipath/apollo-wind';
66
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -12,6 +12,7 @@ import {
1212
createNode,
1313
StoryInfoPanel,
1414
useCanvasStory,
15+
useNodeTypesFromRegistry,
1516
withCanvasProviders,
1617
} from '../../storybook-utils';
1718
import { DefaultCanvasTranslations } from '../../types';
@@ -915,6 +916,141 @@ function AnatomyStory() {
915916
</div>
916917
</section>
917918

919+
<div className="h-px bg-border" />
920+
921+
{/* ── Action Needed ── */}
922+
<section style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
923+
<div>
924+
<h2 className="text-base font-semibold">Action Needed</h2>
925+
<p className="mt-1 text-sm text-foreground-muted">
926+
A new execution state where the node requires human input before the process can
927+
continue. Visually borrows from the Pause palette (amber/<code className="rounded bg-surface-overlay px-1 font-mono text-xs">--color-warning-icon</code>) to
928+
signal a temporary halt that needs attention rather than an error.
929+
</p>
930+
</div>
931+
932+
{/* Visual preview */}
933+
<div className="flex flex-col gap-6 rounded-xl border border-border bg-surface p-6">
934+
935+
<div className="grid grid-cols-2 gap-8">
936+
937+
{/* Adornment slot */}
938+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
939+
<div className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Adornment slot — top-right</div>
940+
<div className="flex items-center gap-3">
941+
<span
942+
className="flex shrink-0 items-center justify-center rounded-full shadow-sm"
943+
style={{ width: 20, height: 20, backgroundColor: 'var(--color-warning-icon)' }}
944+
>
945+
<CanvasIcon icon="hand" size={12} color="#451a03" />
946+
</span>
947+
<p className="text-xs leading-relaxed text-foreground-muted">
948+
Same corner slot as the standard execution status icon. Amber color matches
949+
Pause — both signal a voluntary stop waiting for intervention.
950+
</p>
951+
</div>
952+
</div>
953+
954+
{/* Button */}
955+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
956+
<div className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Action button — below node</div>
957+
<div className="flex items-center gap-3">
958+
<button
959+
type="button"
960+
className="flex shrink-0 items-center gap-1.5 rounded-full bg-amber-400 px-3 py-1 text-[12px] font-semibold text-amber-950 shadow-sm"
961+
>
962+
<CanvasIcon icon="hand" size={12} />
963+
Take Action
964+
</button>
965+
<p className="text-xs leading-relaxed text-foreground-muted">
966+
Floats 8 px below the node. Dark text on amber satisfies WCAG AA contrast.
967+
Clicking opens the action dialog.
968+
</p>
969+
</div>
970+
</div>
971+
972+
</div>
973+
974+
{/* Node mock */}
975+
<div>
976+
<div className="mb-3 text-xs font-semibold uppercase tracking-wide text-foreground-muted">Full appearance — node + adornment + button</div>
977+
<div className="flex items-start justify-center gap-16 py-4">
978+
{[
979+
{ shape: 'circle', borderRadius: '50%', width: 56, height: 56 },
980+
{ shape: 'square', borderRadius: 12, width: 56, height: 56 },
981+
{ shape: 'rectangle', borderRadius: 12, width: 96, height: 56 },
982+
].map(({ shape, borderRadius, width, height }) => (
983+
<div key={shape} className="flex flex-col items-center gap-2">
984+
<div className="relative" style={{ width, height }}>
985+
{/* Node body */}
986+
<div
987+
className="absolute inset-0 border-2 border-amber-400/50 bg-surface shadow-sm"
988+
style={{ borderRadius }}
989+
/>
990+
{/* Node icon placeholder */}
991+
<div className="absolute inset-0 flex items-center justify-center">
992+
<div className="h-4 w-4 rounded bg-foreground-muted/20" />
993+
</div>
994+
{/* Top-right adornment — amber circle with white hand icon */}
995+
<span
996+
className="absolute flex items-center justify-center rounded-full shadow-sm"
997+
style={{ top: -6, right: -6, width: 16, height: 16, backgroundColor: 'var(--color-warning-icon)' }}
998+
>
999+
<CanvasIcon icon="hand" size={10} color="#451a03" />
1000+
</span>
1001+
</div>
1002+
{/* Button below */}
1003+
<button
1004+
type="button"
1005+
className="flex items-center gap-1 rounded-full bg-amber-400 px-2.5 py-0.5 text-[11px] font-semibold text-amber-950 shadow-sm"
1006+
>
1007+
<CanvasIcon icon="hand" size={10} />
1008+
Take Action
1009+
</button>
1010+
<span className="text-[10px] text-foreground-muted">{shape}</span>
1011+
</div>
1012+
))}
1013+
</div>
1014+
</div>
1015+
1016+
</div>
1017+
1018+
{/* Design notes */}
1019+
<div className="rounded-xl border border-border bg-surface-overlay px-5 py-4 text-[12px] text-foreground-muted space-y-2">
1020+
<p><span className="font-semibold text-foreground">Color — </span>Amber reuses <code className="rounded bg-surface px-1 font-mono text-[10px]">--color-warning-icon</code>, matching the Pause state. This creates a visual language: amber = "waiting on something" — either a system pause or a human action.</p>
1021+
<p><span className="font-semibold text-foreground">Contrast — </span>The action button uses <code className="rounded bg-surface px-1 font-mono text-[10px]">text-amber-950</code> (near-black) on <code className="rounded bg-surface px-1 font-mono text-[10px]">bg-amber-400</code>. This achieves a contrast ratio of ~9:1, well above the WCAG AA threshold of 4.5:1 — resolving the original concern about white text on yellow.</p>
1022+
<p><span className="font-semibold text-foreground">Button placement — </span>The button floats below the node rather than inside the header to avoid crowding the label and status icon. This also makes it easy to find by eye when scanning a busy canvas — it breaks the node boundary intentionally as a "call to action" affordance.</p>
1023+
</div>
1024+
1025+
{/* Reference table */}
1026+
<div className="overflow-hidden rounded-xl border border-border">
1027+
<table className="w-full text-sm">
1028+
<thead>
1029+
<tr className="border-b border-border bg-surface-overlay">
1030+
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-foreground-muted">Property</th>
1031+
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-foreground-muted">Value</th>
1032+
</tr>
1033+
</thead>
1034+
<tbody>
1035+
{([
1036+
{ prop: 'Icon', value: 'hand (Lucide) — top-right adornment slot' },
1037+
{ prop: 'Icon color', value: 'var(--color-warning-icon) — same as Pause' },
1038+
{ prop: 'Button bg', value: 'bg-amber-400' },
1039+
{ prop: 'Button text', value: 'text-amber-950 (dark, ~9:1 contrast ratio)' },
1040+
{ prop: 'Button placement', value: '8 px below node bottom edge, horizontally centered' },
1041+
{ prop: 'On click', value: 'Opens action dialog / panel (implementation TBD)' },
1042+
] as const).map(({ prop, value }, i, arr) => (
1043+
<tr key={prop} className={i < arr.length - 1 ? 'border-b border-border' : ''}>
1044+
<td className="px-4 py-3"><code className="font-mono text-xs font-semibold">{prop}</code></td>
1045+
<td className="px-4 py-3 text-xs text-foreground-muted">{value}</td>
1046+
</tr>
1047+
))}
1048+
</tbody>
1049+
</table>
1050+
</div>
1051+
1052+
</section>
1053+
9181054
</div>
9191055
</div>
9201056
);
@@ -1033,6 +1169,24 @@ function LoopCountPill({ count }: { count: number }) {
10331169
);
10341170
}
10351171

1172+
function ActionNeededAdornment() {
1173+
return (
1174+
<CanvasTooltip content="Action needed" placement="bottom">
1175+
<span
1176+
className="flex items-center justify-center rounded-full shadow-sm"
1177+
style={{
1178+
display: 'inline-flex',
1179+
width: 16,
1180+
height: 16,
1181+
backgroundColor: 'var(--color-warning-icon)',
1182+
}}
1183+
>
1184+
<CanvasIcon icon="hand" size={10} color="#451a03" />
1185+
</span>
1186+
</CanvasTooltip>
1187+
);
1188+
}
1189+
10361190
// Custom resolver for V2: moves the loop count out of the status icon (top-right)
10371191
// into a dedicated pill slot (bottom-right), leaving the status icon uncluttered.
10381192
function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments {
@@ -1045,6 +1199,10 @@ function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments {
10451199
typeof executionState === 'object' && executionState?.isExecutionStartPoint;
10461200
const isOutputPinned = typeof executionState === 'object' && executionState?.isOutputPinned;
10471201

1202+
if (status === 'ActionNeeded') {
1203+
return { topRight: <ActionNeededAdornment /> };
1204+
}
1205+
10481206
const hasValidationError =
10491207
context.validationState?.validationStatus === ValidationErrorSeverity.ERROR ||
10501208
context.validationState?.validationStatus === ValidationErrorSeverity.CRITICAL;
@@ -1079,6 +1237,54 @@ function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments {
10791237
};
10801238
}
10811239

1240+
function ActionNeededCanvasNode(props: NodeProps<Node<BaseNodeData>>) {
1241+
// props.height is set by ReactFlow after measurement; DEFAULT_NODE_SIZE = 96 is the safe fallback
1242+
const nodeHeight = props.height ?? 96;
1243+
// Get BaseNode via the registry hook to avoid a circular module-initialization dependency.
1244+
// Importing BaseNode directly here would trigger: BaseNode.tsx → adornment-resolver →
1245+
// canvas/index.ts → HierarchicalCanvas.tsx (DEFAULT_NODE_TYPES = { default: BaseNode })
1246+
// while BaseNode.tsx is still evaluating → TDZ crash.
1247+
const nodeTypes = useNodeTypesFromRegistry();
1248+
const NodeComponent = nodeTypes.default as React.ComponentType<NodeProps<Node<BaseNodeData>>>;
1249+
return (
1250+
<>
1251+
{/* Override type with data.nodeType so BaseNode resolves the correct manifest (shape/icon) */}
1252+
<NodeComponent {...props} type={props.data.nodeType as string} />
1253+
{/* Position relative to ReactFlow's own .react-flow__node wrapper (position:absolute),
1254+
so top: nodeHeight + 8 lands exactly 8px below the node's bottom edge */}
1255+
<div
1256+
className="nodrag nopan pointer-events-auto"
1257+
style={{
1258+
position: 'absolute',
1259+
// NodeLabel renders at nodeHeight+8 (bottom:-8 + translate-y-full) and is ~36px tall
1260+
// (label line 18px + subLabel line 18px). Add 8px gap → nodeHeight + 52.
1261+
top: nodeHeight + 52,
1262+
left: '50%',
1263+
transform: 'translateX(-50%)',
1264+
zIndex: 10,
1265+
whiteSpace: 'nowrap',
1266+
}}
1267+
>
1268+
<button
1269+
type="button"
1270+
className="nodrag nopan flex items-center gap-1.5 rounded-full bg-amber-400 px-3 py-1 text-[12px] font-semibold text-amber-950 shadow-sm transition-colors hover:bg-amber-300"
1271+
onClick={(e) => e.stopPropagation()}
1272+
onPointerDown={(e) => e.stopPropagation()}
1273+
>
1274+
<CanvasIcon icon="hand" size={12} />
1275+
Take Action
1276+
</button>
1277+
</div>
1278+
</>
1279+
);
1280+
}
1281+
1282+
const ACTION_NEEDED_NODE_TYPES = {
1283+
'uipath.manual-trigger-action': ActionNeededCanvasNode,
1284+
'uipath.blank-node-action': ActionNeededCanvasNode,
1285+
'uipath.agent-action': ActionNeededCanvasNode,
1286+
};
1287+
10821288
// ============================================================================
10831289
// Node Anatomy Diagram
10841290
// ============================================================================
@@ -1236,17 +1442,19 @@ const ADORNMENT_ROWS = [
12361442
{ key: 'square-dashed', label: 'Square Dashed (bottom-right)' },
12371443
{ key: 'all', label: 'All Adornments' },
12381444
{ key: 'multi-exec', label: 'Multi-execution (count: 5)' },
1445+
{ key: 'action-needed', label: 'Action Needed' },
12391446
] as const;
12401447

12411448
function createAdornmentGrid(): Node<BaseNodeData>[] {
12421449
const nodes: Node<BaseNodeData>[] = [];
12431450

12441451
ADORNMENT_ROWS.forEach((row, rowIndex) => {
12451452
SHAPES.forEach(({ shape, nodeType }, colIndex) => {
1453+
const isActionNeeded = row.key === 'action-needed';
12461454
nodes.push(
12471455
createNode({
12481456
id: `adorn-${row.key}-${shape}`,
1249-
type: nodeType,
1457+
type: isActionNeeded ? `${nodeType}-action` : nodeType,
12501458
position: {
12511459
x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX,
12521460
y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY,
@@ -1291,6 +1499,8 @@ function getAdornmentExecutionState(key: string) {
12911499
};
12921500
case 'multi-exec':
12931501
return { status: 'Completed' as const, count: 5 };
1502+
case 'action-needed':
1503+
return { status: 'ActionNeeded' as string };
12941504
default:
12951505
return undefined;
12961506
}
@@ -1330,11 +1540,70 @@ const ADORNMENT_DESCRIPTIONS: { label: string; description: string }[] = [
13301540
description:
13311541
'Loop pill in the bottom-right corner: repeat icon + count. Separate from the execution status icon (top-right) so both are visible at once.',
13321542
},
1543+
{
1544+
label: 'Action Needed',
1545+
description: 'Node requires human input before execution can continue. Hand icon (top-right) uses the same warning amber as Pause. A "Take Action" button appears below the node.',
1546+
},
13331547
];
13341548

1549+
// Stable module-scope reference: triggers fitView on first load without repositioning nodes
1550+
const fitViewOnLoad = async () => {};
1551+
1552+
function ActionNeededStory() {
1553+
const initialNodes = useMemo<Node<BaseNodeData>[]>(
1554+
() => [
1555+
createNode({
1556+
id: 'action-needed-circle',
1557+
type: 'uipath.manual-trigger-action',
1558+
position: { x: 200, y: 200 },
1559+
data: {
1560+
nodeType: 'uipath.manual-trigger',
1561+
version: '1.0.0',
1562+
display: { label: 'Trigger', subLabel: 'Action Needed', shape: 'circle' },
1563+
},
1564+
}),
1565+
createNode({
1566+
id: 'action-needed-square',
1567+
type: 'uipath.blank-node-action',
1568+
position: { x: 450, y: 200 },
1569+
data: {
1570+
nodeType: 'uipath.blank-node',
1571+
version: '1.0.0',
1572+
display: { label: 'Task', subLabel: 'Action Needed', shape: 'square' },
1573+
},
1574+
}),
1575+
createNode({
1576+
id: 'action-needed-rectangle',
1577+
type: 'uipath.agent-action',
1578+
position: { x: 700, y: 200 },
1579+
data: {
1580+
nodeType: 'uipath.agent',
1581+
version: '1.0.0',
1582+
display: { label: 'Agent', subLabel: 'Action Needed', shape: 'rectangle' },
1583+
},
1584+
}),
1585+
],
1586+
[]
1587+
);
1588+
1589+
const { canvasProps } = useCanvasStory({ initialNodes, additionalNodeTypes: ACTION_NEEDED_NODE_TYPES });
1590+
1591+
return (
1592+
<BaseCanvas {...canvasProps} mode="design" initialAutoLayout={fitViewOnLoad}>
1593+
<Panel position="bottom-right">
1594+
<CanvasPositionControls translations={DefaultCanvasTranslations} />
1595+
</Panel>
1596+
<StoryInfoPanel
1597+
title="Action Needed"
1598+
description="A new execution state where the node requires human input before the process can continue. Amber adornment in the top-right corner + Take Action button below the node."
1599+
/>
1600+
</BaseCanvas>
1601+
);
1602+
}
1603+
13351604
function AdornmentsStory() {
13361605
const initialNodes = useMemo(() => createAdornmentGrid(), []);
1337-
const { canvasProps } = useCanvasStory({ initialNodes });
1606+
const { canvasProps } = useCanvasStory({ initialNodes, additionalNodeTypes: ACTION_NEEDED_NODE_TYPES });
13381607

13391608
return (
13401609
<BaseCanvas {...canvasProps} mode="design">
@@ -1384,6 +1653,27 @@ export const Adornments: Story = {
13841653
render: () => <AdornmentsStory />,
13851654
};
13861655

1656+
export const ActionNeeded: Story = {
1657+
name: 'Action Needed',
1658+
decorators: [
1659+
(Story) => (
1660+
<AdornmentResolverProvider value={resolveAdornmentsV2}>
1661+
<Story />
1662+
</AdornmentResolverProvider>
1663+
),
1664+
withCanvasProviders({
1665+
executionState: {
1666+
getNodeExecutionState: () => ({ status: 'ActionNeeded' as string }),
1667+
getEdgeExecutionState: () => undefined,
1668+
},
1669+
validationState: {
1670+
getElementValidationState: () => undefined,
1671+
},
1672+
}),
1673+
],
1674+
render: () => <ActionNeededStory />,
1675+
};
1676+
13871677
// ============================================================================
13881678
// Stacked Treatment Story
13891679
// ============================================================================

0 commit comments

Comments
 (0)