11import type { Meta , StoryObj } from '@storybook/react' ;
22import { 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' ;
44import { Panel } from '@uipath/apollo-react/canvas/xyflow/react' ;
55import { Button , Input , Label , Slider , Switch } from '@uipath/apollo-wind' ;
66import { 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' ;
1718import { 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.
10381192function 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
12411448function 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+
13351604function 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