diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 05c431da3..f51a766d0 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -113,6 +113,7 @@ const config: StorybookConfig = { plugins: [...(config.plugins || []), tailwindcss()], resolve: { ...config.resolve, + extensions: ['.tsx', '.ts', '.mts', '.jsx', '.js', '.mjs', '.json'], alias: mergeAlias(config.resolve?.alias, [ // ── Apollo Wind → source for HMR ── { find: '@', replacement: apolloWindSrc }, diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index cf1dcdd61..5fb944332 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -165,7 +165,7 @@ const preview: Preview = { '*', ], 'Canvas', - ['Components', ['All Components', '*'], '*'], + ['Components', ['All Components', 'BaseNode', 'BaseNode V2', ['Anatomy', '*'], 'LoopNode', 'LoopNode V2', ['Anatomy', '*'], '*'], '*'], ], }, }, diff --git a/apps/storybook/package.json b/apps/storybook/package.json index c38f591ea..7b8be402e 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@mui/material": "^5.18.0", - "@uipath/ap-chat": "workspace:*", "@uipath/apollo-core": "workspace:*", "@uipath/apollo-react": "workspace:*", "@uipath/apollo-wind": "workspace:*", diff --git a/packages/apollo-react/rslib.config.ts b/packages/apollo-react/rslib.config.ts index b3dc61222..1866de6b0 100644 --- a/packages/apollo-react/rslib.config.ts +++ b/packages/apollo-react/rslib.config.ts @@ -30,7 +30,10 @@ export default defineConfig({ }, externals, }, - dts: true, + dts: { + build: true, + abortOnError: false, + }, bundle: false, }, { diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx index 69e719fa0..9f32f24c4 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx @@ -24,7 +24,7 @@ import { useNodeTypeRegistry } from '../../core'; import { useElementValidationStatus, useNodeExecutionState } from '../../hooks'; import type { NodeShape } from '../../schema'; import type { HandleGroupManifest } from '../../schema/node-definition'; -import { resolveAdornments } from '../../utils/adornment-resolver'; +import { useAdornmentResolver } from '../../utils/adornment-resolver'; import { getIcon } from '../../utils/icon-registry'; import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver'; import { selectIsConnecting } from '../../utils/NodeUtils'; @@ -241,7 +241,9 @@ const BaseNodeComponent = (props: NodeProps>) => { return manifest ? resolveToolbar(manifest, statusContext) : undefined; }, [toolbarConfigProp, manifest, statusContext]); - // Adornments resolution: use default resolver, then override with props if provided + // Adornments resolution: use default resolver, then override with props if provided. + // The resolver can be swapped per-story via AdornmentResolverProvider (V2 prototype use). + const resolveAdornments = useAdornmentResolver(); const adornments: NodeAdornments = useMemo(() => { const adornmentsFromProps = adornmentsProp ?? {}; const adornmentsFromResolver = resolveAdornments(statusContext); @@ -250,7 +252,7 @@ const BaseNodeComponent = (props: NodeProps>) => { ...adornmentsFromResolver, ...adornmentsFromProps, }; - }, [adornmentsProp, statusContext]); + }, [adornmentsProp, resolveAdornments, statusContext]); // Compute height: max of base height (user-specified or measured) and handle minimum. // baseHeightRef is updated above from external height changes; handle inflation diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx new file mode 100644 index 000000000..8003112c6 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNodeV2.stories.tsx @@ -0,0 +1,1940 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Column } from '@uipath/apollo-react/canvas/layouts'; +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Panel } from '@uipath/apollo-react/canvas/xyflow/react'; +import { Button, Input, Label, Slider, Switch } from '@uipath/apollo-wind'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { NodeRegistryProvider } from '../../core'; +import type { CategoryManifest, NodeManifest } from '../../schema'; +import { + allCategoryManifests, + allNodeManifests, + createNode, + StoryInfoPanel, + useCanvasStory, + useNodeTypesFromRegistry, + withCanvasProviders, +} from '../../storybook-utils'; +import { DefaultCanvasTranslations } from '../../types'; +import { ValidationErrorSeverity } from '../../types/validation'; +import { + AdornmentResolverProvider, + BreakpointIndicator, + ExecutionStartPointIndicator, + ExecutionStatusIndicator, + SquareDashedIndicator, + ValidationErrorIndicator, + ValidationWarningIndicator, +} from '../../utils/adornment-resolver'; +import { CanvasIcon } from '../../utils/icon-registry'; +import { CanvasTooltip } from '../CanvasTooltip'; +import { ExecutionStatusIcon } from '../ExecutionStatusIcon'; +import type { BaseNodeData, NodeAdornments, NodeStatusContext } from './BaseNode.types'; +import { BaseCanvas } from '../BaseCanvas'; +import { CanvasPositionControls } from '../CanvasPositionControls'; +import { NodeInspector } from '../NodeInspector'; + +// ============================================================================ +// Meta Configuration +// ============================================================================ + +const meta: Meta = { + title: 'Components/BaseNode V2', + parameters: { + layout: 'fullscreen', + }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================ +// Node Grid Definitions +// ============================================================================ + +const SHAPES = [ + { shape: 'circle', nodeType: 'uipath.manual-trigger' }, + { shape: 'square', nodeType: 'uipath.blank-node' }, + { shape: 'rectangle', nodeType: 'uipath.agent' }, +] as const; +const STATUSES = ['NotExecuted', 'InProgress', 'Completed', 'Failed', 'Paused'] as const; + +const GRID_CONFIG = { + startX: 96, + startY: 96, + gapX: 192, + gapY: 159, +}; + +function createShapeStatusGrid(): Node[] { + const nodes: Node[] = []; + + STATUSES.forEach((status, rowIndex) => { + SHAPES.forEach(({ shape, nodeType }, colIndex) => { + nodes.push( + createNode({ + id: `${shape}-${status}`, + type: nodeType, + position: { + x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + executionStatus: status, + display: { + label: shape, + subLabel: status.replace(/([A-Z])/g, ' $1').trim(), + shape, + }, + }, + }) + ); + }); + }); + + SHAPES.forEach(({ shape, nodeType }, shapeI) => { + nodes.push( + createNode({ + id: `${shape}-loading`, + type: nodeType, + position: { + x: GRID_CONFIG.startX + shapeI * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + STATUSES.length * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + display: { label: shape, shape, subLabel: 'Loading state' }, + loading: true, + }, + }) + ); + }); + + nodes.push( + createNode({ + id: `unknown-node`, + type: 'uipath.unknown-node', + position: { + x: GRID_CONFIG.startX, + y: GRID_CONFIG.startY + (STATUSES.length + 1) * GRID_CONFIG.gapY, + }, + data: { + nodeType: 'uipath.unknown-node', + version: '1.0.0', + display: { label: 'Unknown Node', shape: 'square', subLabel: 'Missing manifest' }, + }, + }) + ); + + nodes.push( + createNode({ + id: `no-icon-node`, + type: 'uipath.no-icon-node', + position: { + x: GRID_CONFIG.startX + GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + (STATUSES.length + 1) * GRID_CONFIG.gapY, + }, + data: { + nodeType: 'uipath.no-icon-node', + version: '1.0.0', + display: { + label: 'Missing Icon', + shape: 'square', + subLabel: 'Fallback to icon w/ first letter', + }, + }, + }) + ); + + return nodes; +} + +// ============================================================================ +// Size Grid Definitions +// ============================================================================ + +const SQUARE_SIZES = [48, 64, 80, 96, 112, 128] as const; +const RECTANGLE_CONFIGS = [ + { width: 128, height: 48 }, + { width: 160, height: 64 }, + { width: 192, height: 80 }, + { width: 256, height: 96 }, + { width: 320, height: 112 }, +] as const; + +function createSizeGrid(): Node[] { + const nodes: Node[] = []; + let xOffset = 96; + + SQUARE_SIZES.forEach((size) => { + nodes.push({ + ...createNode({ + id: `sq-${size}`, + type: 'uipath.blank-node', + position: { x: xOffset, y: 96 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: String(size), shape: 'square' }, + }, + }), + width: size, + height: size, + }); + xOffset += size + 32; + }); + + xOffset = 96; + SQUARE_SIZES.forEach((size) => { + nodes.push({ + ...createNode({ + id: `c-${size}`, + type: 'uipath.blank-node', + position: { x: xOffset, y: 272 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: String(size), shape: 'circle' }, + }, + }), + width: size, + height: size, + }); + xOffset += size + 32; + }); + + let rectX = 96; + let rectY = 448; + RECTANGLE_CONFIGS.forEach(({ width, height }, index) => { + nodes.push({ + ...createNode({ + id: `r-${index}`, + type: 'uipath.agent', + position: { x: rectX, y: rectY }, + data: { + nodeType: 'uipath.agent', + version: '1.0.0', + display: { label: `${width}×${height}`, shape: 'rectangle' }, + }, + }), + width, + height, + }); + + rectX += width + 32; + if (index === 2) { + rectX = 96; + rectY = 560; + } + }); + + return nodes; +} + +// ============================================================================ +// Story Components +// ============================================================================ + +function DefaultStory() { + const initialNodes = useMemo(() => createShapeStatusGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + +function CustomizedSizesStory() { + const initialNodes = useMemo(() => createSizeGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + + ); +} + +const dynamicHandlesManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = { + categories: [ + { + id: 'control', + name: 'Control Flow', + sortOrder: 1, + color: '#6c757d', + colorDark: '#495057', + icon: 'git-branch', + tags: [], + }, + ], + nodes: [ + { + nodeType: 'uipath.control-switch', + version: '1.0.0', + category: 'control', + tags: ['dynamic', 'repeat'], + sortOrder: 1, + display: { + label: 'Dynamic Handle Node', + icon: 'switch', + shape: 'square', + }, + handleConfiguration: [ + { + position: 'left', + handles: [ + { + id: 'input-{index}', + type: 'target', + handleType: 'input', + label: '{item.label}', + repeat: 'inputs.dynamicInputs', + }, + ], + }, + { + position: 'right', + handles: [ + { + id: 'output-{index}', + type: 'source', + handleType: 'output', + label: '{item.name}', + repeat: 'inputs.dynamicOutputs', + }, + { + id: 'default', + type: 'source', + handleType: 'output', + label: 'Default Output', + visible: 'inputs.hasDefault', + }, + ], + }, + ], + }, + { + nodeType: 'uipath.decision', + version: '1.0.0', + category: 'control', + tags: ['control', 'decision'], + sortOrder: 2, + display: { + label: 'Decision', + icon: 'decision', + shape: 'square', + }, + handleConfiguration: [ + { + position: 'left', + handles: [{ id: 'input', type: 'target', handleType: 'input' }], + }, + { + position: 'right', + handles: [ + { id: 'true', type: 'source', handleType: 'output', label: '{inputs.trueLabel}' }, + { id: 'false', type: 'source', handleType: 'output', label: '{inputs.falseLabel}' }, + ], + }, + ], + }, + ], +}; + +function DynamicHandlesStory() { + const [switchData, setSwitchData] = useState({ + dynamicInputs: [ + { label: 'Primary Input' }, + { label: 'Secondary Input' }, + { label: 'Tertiary Input' }, + ], + dynamicOutputs: [{ name: 'Success Path' }, { name: 'Failure Path' }], + hasDefault: false, + }); + + const [decisionData, setDecisionData] = useState({ + trueLabel: 'Approved', + falseLabel: 'Rejected', + }); + + const initialNodes = useMemo(() => { + return [ + { + ...createNode({ + id: 'dynamic-handles-node', + type: 'uipath.control-switch', + position: { x: 700, y: 200 }, + data: { + nodeType: 'uipath.control-switch', + version: '1.0.0', + inputs: { + dynamicInputs: switchData.dynamicInputs, + dynamicOutputs: switchData.dynamicOutputs, + hasDefault: switchData.hasDefault, + }, + display: { + label: 'Dynamic Handles', + subLabel: `${switchData.dynamicInputs.length} inputs, ${switchData.dynamicOutputs.length} outputs`, + shape: 'square', + }, + }, + }), + height: 96, + width: 96, + }, + { + ...createNode({ + id: 'decision-node', + type: 'uipath.decision', + position: { x: 700, y: 600 }, + data: { + nodeType: 'uipath.decision', + version: '1.0.0', + inputs: { + trueLabel: decisionData.trueLabel, + falseLabel: decisionData.falseLabel, + }, + display: { + label: 'Decision', + subLabel: 'Templated labels', + shape: 'square', + }, + }, + }), + height: 96, + width: 96, + }, + ]; + }, [switchData, decisionData]); + + const { canvasProps, setNodes } = useCanvasStory({ initialNodes }); + + useEffect(() => { + setNodes((nodes) => + nodes.map((node) => { + if (node.id === 'dynamic-handles-node') { + return { + ...node, + data: { + ...node.data, + inputs: { + dynamicInputs: switchData.dynamicInputs, + dynamicOutputs: switchData.dynamicOutputs, + hasDefault: switchData.hasDefault, + }, + display: { + ...(node.data.display || {}), + subLabel: `${switchData.dynamicInputs.length} inputs, ${switchData.dynamicOutputs.length} outputs`, + }, + }, + }; + } + if (node.id === 'decision-node') { + return { + ...node, + data: { + ...node.data, + inputs: { + trueLabel: decisionData.trueLabel, + falseLabel: decisionData.falseLabel, + }, + }, + }; + } + return node; + }) + ); + }, [switchData, decisionData, setNodes]); + + const handleInputCount = useCallback((value: number[]) => { + const count = value[0] ?? 0; + setSwitchData((prev) => { + const current = prev.dynamicInputs; + if (count > current.length) { + const added = Array.from({ length: count - current.length }, (_, i) => ({ + label: `Input ${current.length + i + 1}`, + })); + return { ...prev, dynamicInputs: [...current, ...added] }; + } + return { ...prev, dynamicInputs: current.slice(0, count) }; + }); + }, []); + + const handleOutputCount = useCallback((value: number[]) => { + const count = value[0] ?? 0; + setSwitchData((prev) => { + const current = prev.dynamicOutputs; + if (count > current.length) { + const added = Array.from({ length: count - current.length }, (_, i) => ({ + name: `Output ${current.length + i + 1}`, + })); + return { ...prev, dynamicOutputs: [...current, ...added] }; + } + return { ...prev, dynamicOutputs: current.slice(0, count) }; + }); + }, []); + + return ( + + + + + + + + + + + + + + + + +
+ + setSwitchData((prev) => ({ ...prev, hasDefault: checked })) + } + /> + +
+
+ + + + + + + setDecisionData((prev) => ({ ...prev, trueLabel: e.target.value })) + } + /> + + + + + setDecisionData((prev) => ({ ...prev, falseLabel: e.target.value })) + } + /> + + + + +
+
+
+ ); +} + +// ============================================================================ +// Anatomy Story — full-page documentation layout +// ============================================================================ + +const SHAPE_DOCS = [ + { + label: 'Circle', + icon: 'repeat', + usage: 'Trigger / start nodes', + code: 'shape: "circle"', + borderRadius: '50%', + size: { width: 64, height: 64 }, + }, + { + label: 'Square', + icon: 'agent', + usage: 'Standard action nodes', + code: 'shape: "square"', + borderRadius: 16, + size: { width: 64, height: 64 }, + }, + { + label: 'Rectangle', + icon: 'agent', + usage: 'Agent / wide nodes', + code: 'shape: "rectangle"', + borderRadius: 16, + size: { width: 96, height: 64 }, + }, +] as const; + +const SLOT_DOCS = [ + { slot: 'topLeft', dot: 'bg-red-500', rule: 'Breakpoint', detail: 'Debug mode — pauses execution at this node.' }, + { slot: 'topRight', dot: 'bg-emerald-500', rule: 'Status › Validation error › Warning', detail: 'First matching state wins. Clears when execution ends.' }, + { slot: 'bottomLeft', dot: 'bg-blue-500', rule: 'Execution start point', detail: 'Marks the entry node for the current run.' }, + { slot: 'bottomRight', dot: 'bg-amber-500', rule: 'Loop count (> 1) › Output pinned', detail: 'Loop count takes priority when both are active.' }, +] as const; + +function AnatomyStory() { + return ( +
+
+ + {/* ── Page header ── */} +
+

BaseNode Anatomy

+

+ BaseNode renders in three shapes. Four 20×20 px adornment slots sit at each corner of the + node — each with a defined priority chain that determines what content to show. +

+
+ +
+ + {/* ── Shapes ── */} +
+
+

Shapes

+

+ Controlled by display.shape on + the node data or manifest. All shapes share the same slot anatomy. +

+
+
+ {SHAPE_DOCS.map(({ label, icon, usage, code, borderRadius, size }) => ( +
+
+ +
+
+
{label}
+
{usage}
+
+ + {code} + +
+ ))} +
+
+ +
+ + {/* ── Adornment Slots ── */} +
+
+

Adornment Slots

+

+ Four 20×20 px slots at each corner. Each slot runs its priority chain and renders the + first matching condition. Two placement options are under consideration. +

+
+ + {/* Option A / Option B comparison */} +
+ + {/* Option A — inside border */} +
+
+

Option A — Inside border

+
    +
  • Never overflows node bounds — safe for dense canvas layouts where nodes sit close together.
  • +
  • Predictable hit area — all interactions stay within the node boundary.
  • +
  • Competes with node content near corners, reducing usable space.
  • +
  • Badge can visually merge with the node background, reducing contrast.
  • +
+
+
+
+ {/* Left labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( +
+
+
{slot}
+
{rule}
+
+
+
+
+
+
+ ))} +
+ {/* Node mock — inset 6px */} +
+
+
+ +
+
+
+
+
+
+
+ {/* Right labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( +
+
+
+
+
+
+
{slot}
+
{rule}
+
+
+ ))} +
+
+
+
+ + {/* Option B — on border (50/50) */} +
+
+

Option B — On border

+
    +
  • Familiar notification badge pattern — immediately recognisable to most users.
  • +
  • Pops clearly against both the node background and the canvas — stronger contrast.
  • +
  • Extends slightly outside node bounds — may overlap adjacent nodes or edges at tight spacing.
  • +
  • Canvas layout needs to account for the overflow margin around each node.
  • +
+
+
+
+ {/* Left labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot, rule }) => ( +
+
+
{slot}
+
{rule}
+
+
+
+
+
+
+ ))} +
+ {/* Node mock — on border (-10px = -½ × 20px slot size) */} +
+
+
+ +
+
+
+
+
+
+
+ {/* Right labels */} +
+ {SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot, rule }) => ( +
+
+
+
+
+
+
{slot}
+
{rule}
+
+
+ ))} +
+
+
+
+ +
+ + {/* Key trade-off callout */} +
+ Key trade-off — + Option A is the safer choice when canvas nodes are densely packed, since badges never stray outside the node boundary. Option B is more visually distinct and follows a pattern users already recognise from notification systems, but requires the canvas layout to reserve a small overflow margin around each node to avoid clipping into neighbours. +
+ + {/* Reference table */} +
+ + + + + + + + + + {SLOT_DOCS.map(({ slot, dot, rule, detail }, i) => ( + + + + + + ))} + +
SlotPriority chainNotes
+
+
+ {slot} +
+
{rule}{detail}
+
+
+ +
+ + {/* ── V2 Iterations ── */} +
+
+

V2 Iterations

+

+ Improvements introduced in the V2 prototype. All changes are scoped to BaseNode V2 + only — the original BaseNode story is unaffected. +

+
+ + {/* Subsection: Loop Count Pill */} +
+

Loop Count Badge

+

+ The ↻ N badge + shows how many times this node has executed. For a node inside a loop, N equals the + number of loop iterations completed — it increments by 1 after each iteration. + The badge only appears when N > 1. +

+ + {/* V1 vs V2 comparison */} +
+
+
+
V1 — count inline
+
+ 3 + +
+

Count prefixes the status icon. Both pieces of information share one slot.

+
+ +
+ +
+
V2 — dedicated slots
+
+
+ + topRight +
+
+ + bottomRight +
+
+

Status and count each occupy their own corner slot — both visible simultaneously.

+
+
+
+ + {/* Reference table */} +
+ + + + + + + + + {([ + { prop: 'Badge', value: '↻ repeat icon + count number' }, + { prop: 'Slot', value: 'bottomRight adornment' }, + { prop: 'Visible when', value: 'count > 1' }, + { prop: 'What N means', value: 'Times this node has run — one per completed loop iteration' }, + { prop: 'Tooltip', value: '"Executed N times"' }, + { prop: 'Priority', value: 'Overrides the output-pinned badge when both are active' }, + ] as const).map(({ prop, value }, i, arr) => ( + + + + + ))} + +
PropertyValue
{prop}{value}
+
+
+
+ +
+ + {/* ── Action Needed ── */} +
+
+

Action Needed

+

+ A new execution state where the node requires human input before the process can + continue. Visually borrows from the Pause palette (amber/--color-warning-icon) to + signal a temporary halt that needs attention rather than an error. +

+
+ + {/* Visual preview */} +
+ +
+ + {/* Adornment slot */} +
+
Adornment slot — top-right
+
+ + + +

+ Same corner slot as the standard execution status icon. Amber color matches + Pause — both signal a voluntary stop waiting for intervention. +

+
+
+ + {/* Button */} +
+
Action button — below node
+
+ +

+ Floats 8 px below the node. Dark text on amber satisfies WCAG AA contrast. + Clicking opens the action dialog. +

+
+
+ +
+ + {/* Node mock */} +
+
Full appearance — node + adornment + button
+
+ {[ + { shape: 'circle', borderRadius: '50%', width: 56, height: 56 }, + { shape: 'square', borderRadius: 12, width: 56, height: 56 }, + { shape: 'rectangle', borderRadius: 12, width: 96, height: 56 }, + ].map(({ shape, borderRadius, width, height }) => ( +
+
+ {/* Node body */} +
+ {/* Node icon placeholder */} +
+
+
+ {/* Top-right adornment — amber circle with white hand icon */} + + + +
+ {/* Button below */} + + {shape} +
+ ))} +
+
+ +
+ + {/* Design notes */} +
+

Color — Amber reuses --color-warning-icon, matching the Pause state. This creates a visual language: amber = "waiting on something" — either a system pause or a human action.

+

Contrast — The action button uses text-amber-950 (near-black) on bg-amber-400. 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.

+

Button placement — 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.

+
+ + {/* Reference table */} +
+ + + + + + + + + {([ + { prop: 'Icon', value: 'hand (Lucide) — top-right adornment slot' }, + { prop: 'Icon color', value: 'var(--color-warning-icon) — same as Pause' }, + { prop: 'Button bg', value: 'bg-amber-400' }, + { prop: 'Button text', value: 'text-amber-950 (dark, ~9:1 contrast ratio)' }, + { prop: 'Button placement', value: '8 px below node bottom edge, horizontally centered' }, + { prop: 'On click', value: 'Opens action dialog / panel (implementation TBD)' }, + ] as const).map(({ prop, value }, i, arr) => ( + + + + + ))} + +
PropertyValue
{prop}{value}
+
+ +
+ +
+
+ ); +} + +// ============================================================================ +// Exported Stories +// ============================================================================ + +export const Anatomy: Story = { + name: 'Anatomy', + render: () => , +}; + +export const Default: Story = { + name: 'Default', + render: () => , +}; + +export const CustomizedSizes: Story = { + name: 'Customized sizes', + render: () => , +}; + +export const DynamicHandles: Story = { + name: 'Dynamic Handles', + decorators: [ + (Story) => ( + + + + ), + ], + render: () => , +}; + +// ============================================================================ +// Validation States Story +// ============================================================================ + +const VALIDATION_SEVERITIES = ['WARNING', 'ERROR', 'CRITICAL'] as const; + +const validationMessages: Record = { + WARNING: 'Trigger should be connected to at least one node', + ERROR: 'URL is required', + CRITICAL: 'Node configuration is invalid', +}; + +function createValidationGrid(): Node[] { + const nodes: Node[] = []; + + VALIDATION_SEVERITIES.forEach((severity, rowIndex) => { + SHAPES.forEach(({ shape, nodeType }, colIndex) => { + nodes.push( + createNode({ + id: `validation-${shape}-${severity}`, + type: nodeType, + position: { + x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + display: { + label: shape, + subLabel: severity, + shape, + }, + }, + }) + ); + }); + }); + + return nodes; +} + +function ValidationStatesStory() { + const initialNodes = useMemo(() => createValidationGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + +// ============================================================================ +// Option B: Loop Count Pill (V2 prototype — does not affect original BaseNode) +// ============================================================================ + +function LoopCountPill({ count }: { count: number }) { + return ( + +
+ + + {count} + +
+
+ ); +} + +function ActionNeededAdornment() { + return ( + + + + + + ); +} + +// Custom resolver for V2: moves the loop count out of the status icon (top-right) +// into a dedicated pill slot (bottom-right), leaving the status icon uncluttered. +function resolveAdornmentsV2(context: NodeStatusContext): NodeAdornments { + const executionState = context.executionState; + + const status = typeof executionState === 'object' ? executionState?.status : executionState; + const count = typeof executionState === 'object' ? executionState.count : undefined; + const hasBreakpoint = typeof executionState === 'object' && executionState?.debug; + const isExecutionStartPoint = + typeof executionState === 'object' && executionState?.isExecutionStartPoint; + const isOutputPinned = typeof executionState === 'object' && executionState?.isOutputPinned; + + if (status === 'ActionNeeded') { + return { topRight: }; + } + + const hasValidationError = + context.validationState?.validationStatus === ValidationErrorSeverity.ERROR || + context.validationState?.validationStatus === ValidationErrorSeverity.CRITICAL; + const hasValidationWarning = + context.validationState?.validationStatus === ValidationErrorSeverity.WARNING; + + const getTopRight = () => { + if (status && status !== 'None') return ; + if (hasValidationError) + return ( + + ); + if (hasValidationWarning) + return ( + + ); + return undefined; + }; + + const hasLoopCount = count !== undefined && count > 1; + + return { + topLeft: hasBreakpoint ? : undefined, + topRight: getTopRight(), + bottomLeft: isExecutionStartPoint ? : undefined, + // Loop count takes priority over output-pinned when both are present + bottomRight: hasLoopCount ? ( + + ) : isOutputPinned ? ( + + ) : undefined, + }; +} + +function ActionNeededCanvasNode(props: NodeProps>) { + // props.height is set by ReactFlow after measurement; DEFAULT_NODE_SIZE = 96 is the safe fallback + const nodeHeight = props.height ?? 96; + // Get BaseNode via the registry hook to avoid a circular module-initialization dependency. + // Importing BaseNode directly here would trigger: BaseNode.tsx → adornment-resolver → + // canvas/index.ts → HierarchicalCanvas.tsx (DEFAULT_NODE_TYPES = { default: BaseNode }) + // while BaseNode.tsx is still evaluating → TDZ crash. + const nodeTypes = useNodeTypesFromRegistry(); + const NodeComponent = nodeTypes.default as React.ComponentType>>; + return ( + <> + {/* Override type with data.nodeType so BaseNode resolves the correct manifest (shape/icon) */} + + {/* Position relative to ReactFlow's own .react-flow__node wrapper (position:absolute), + so top: nodeHeight + 8 lands exactly 8px below the node's bottom edge */} +
+ +
+ + ); +} + +const ACTION_NEEDED_NODE_TYPES = { + 'uipath.manual-trigger-action': ActionNeededCanvasNode, + 'uipath.blank-node-action': ActionNeededCanvasNode, + 'uipath.agent-action': ActionNeededCanvasNode, +}; + +// ============================================================================ +// Node Anatomy Diagram +// ============================================================================ + +const SLOT_LEGEND = [ + { + label: 'topLeft', + dot: 'bg-red-500', + rule: 'Breakpoint', + detail: 'Debug mode only — pauses execution at this node.', + side: 'left', + }, + { + label: 'topRight', + dot: 'bg-emerald-500', + rule: 'Status › Validation error › Warning', + detail: 'First matching state wins. Clears when execution ends.', + side: 'right', + }, + { + label: 'bottomLeft', + dot: 'bg-blue-500', + rule: 'Execution start point', + detail: 'Marks the entry node for the current run.', + side: 'left', + }, + { + label: 'bottomRight', + dot: 'bg-amber-500', + rule: 'Loop count (> 1) › Output pinned', + detail: 'Loop count takes priority when both are active.', + side: 'right', + }, +] as const; + +function NodeAnatomyDiagram() { + const leftSlots = SLOT_LEGEND.filter((s) => s.side === 'left'); + const rightSlots = SLOT_LEGEND.filter((s) => s.side === 'right'); + + return ( + + {/* Diagram: labels flanking the real node mock */} +
+ {/* Left labels — aligned to top and bottom of node */} +
+ {leftSlots.map(({ label, dot, rule }) => ( +
+ + {label} + {rule} + +
+
+ ))} +
+ + {/* The node — uses exact BaseNode styles at 96 × 96 px */} +
+ {/* Outer container */} +
+ {/* Inner shape */} +
+ +
+
+ + {/* topLeft — Breakpoint */} +
+
+
+ + {/* topRight — Execution status (Completed) */} +
+ +
+ + {/* bottomLeft — Start point */} +
+
+
+ + {/* bottomRight — Loop count pill */} +
+ +
+
+ + {/* Right labels */} +
+ {rightSlots.map(({ label, dot, rule }) => ( +
+
+ + {label} + {rule} + +
+ ))} +
+
+ +

+ Each slot is 20 × 20 px, inset 6 px from the node corner. +

+ +
+ + {/* Full slot details */} + + {SLOT_LEGEND.map(({ label, dot, rule, detail }) => ( +
+
+ +
+ {label} + {rule} +
+ {detail} +
+
+ ))} + + + ); +} + +// ============================================================================ +// Adornments Story +// ============================================================================ + +const ADORNMENT_ROWS = [ + { key: 'breakpoint', label: 'Breakpoint (top-left)' }, + { key: 'status-completed', label: 'Status: Completed (top-right)' }, + { key: 'status-inprogress', label: 'Status: InProgress (top-right)' }, + { key: 'status-failed', label: 'Status: Failed (top-right)' }, + { key: 'start-point', label: 'Start Point (bottom-left)' }, + { key: 'square-dashed', label: 'Square Dashed (bottom-right)' }, + { key: 'all', label: 'All Adornments' }, + { key: 'multi-exec', label: 'Multi-execution (count: 5)' }, + { key: 'action-needed', label: 'Action Needed' }, +] as const; + +function createAdornmentGrid(): Node[] { + const nodes: Node[] = []; + + ADORNMENT_ROWS.forEach((row, rowIndex) => { + SHAPES.forEach(({ shape, nodeType }, colIndex) => { + const isActionNeeded = row.key === 'action-needed'; + nodes.push( + createNode({ + id: `adorn-${row.key}-${shape}`, + type: isActionNeeded ? `${nodeType}-action` : nodeType, + position: { + x: GRID_CONFIG.startX + colIndex * GRID_CONFIG.gapX, + y: GRID_CONFIG.startY + rowIndex * GRID_CONFIG.gapY, + }, + data: { + nodeType, + version: '1.0.0', + display: { + label: shape, + subLabel: row.label, + shape, + }, + }, + }) + ); + }); + }); + + return nodes; +} + +function getAdornmentExecutionState(key: string) { + switch (key) { + case 'breakpoint': + return { status: 'None' as const, debug: true }; + case 'status-completed': + return { status: 'Completed' as const }; + case 'status-inprogress': + return { status: 'InProgress' as const }; + case 'status-failed': + return { status: 'Failed' as const }; + case 'start-point': + return { status: 'None' as const, isExecutionStartPoint: true }; + case 'square-dashed': + return { status: 'None' as const, isOutputPinned: true }; + case 'all': + return { + status: 'Completed' as const, + debug: true, + isExecutionStartPoint: true, + isOutputPinned: true, + }; + case 'multi-exec': + return { status: 'Completed' as const, count: 5 }; + case 'action-needed': + return { status: 'ActionNeeded' as string }; + default: + return undefined; + } +} + +const ADORNMENT_DESCRIPTIONS: { label: string; description: string }[] = [ + { + label: 'Breakpoint', + description: 'Pauses execution at this node during a debug run.', + }, + { + label: 'Status: Completed', + description: 'Node finished executing successfully.', + }, + { + label: 'Status: In Progress', + description: 'Node is actively running.', + }, + { + label: 'Status: Failed', + description: 'Node encountered an error during execution.', + }, + { + label: 'Start Point', + description: 'Entry point for the current execution run.', + }, + { + label: 'Square Dashed', + description: 'Output is pinned — result saved for later reference.', + }, + { + label: 'All Adornments', + description: 'All four corners populated at the same time.', + }, + { + label: 'Multi-execution', + description: + 'Loop pill in the bottom-right corner: repeat icon + count. Separate from the execution status icon (top-right) so both are visible at once.', + }, + { + label: 'Action Needed', + 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.', + }, +]; + +// Stable module-scope reference: triggers fitView on first load without repositioning nodes +const fitViewOnLoad = async () => {}; + +function ActionNeededStory() { + const initialNodes = useMemo[]>( + () => [ + createNode({ + id: 'action-needed-circle', + type: 'uipath.manual-trigger-action', + position: { x: 200, y: 200 }, + data: { + nodeType: 'uipath.manual-trigger', + version: '1.0.0', + display: { label: 'Trigger', subLabel: 'Action Needed', shape: 'circle' }, + }, + }), + createNode({ + id: 'action-needed-square', + type: 'uipath.blank-node-action', + position: { x: 450, y: 200 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: 'Task', subLabel: 'Action Needed', shape: 'square' }, + }, + }), + createNode({ + id: 'action-needed-rectangle', + type: 'uipath.agent-action', + position: { x: 700, y: 200 }, + data: { + nodeType: 'uipath.agent', + version: '1.0.0', + display: { label: 'Agent', subLabel: 'Action Needed', shape: 'rectangle' }, + }, + }), + ], + [] + ); + + const { canvasProps } = useCanvasStory({ initialNodes, additionalNodeTypes: ACTION_NEEDED_NODE_TYPES }); + + return ( + + + + + + + ); +} + +function AdornmentsStory() { + const initialNodes = useMemo(() => createAdornmentGrid(), []); + const { canvasProps } = useCanvasStory({ initialNodes, additionalNodeTypes: ACTION_NEEDED_NODE_TYPES }); + + return ( + + + + + + + {ADORNMENT_DESCRIPTIONS.map(({ label, description }) => ( + + {label} + {description} + + ))} + + + + ); +} + +export const Adornments: Story = { + name: 'Adornments', + decorators: [ + (Story) => ( + + + + ), + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => { + const parts = nodeId.split('-'); + const key = parts.slice(1, -1).join('-'); + return getAdornmentExecutionState(key); + }, + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; + +export const ActionNeeded: Story = { + name: 'Action Needed', + decorators: [ + (Story) => ( + + + + ), + withCanvasProviders({ + executionState: { + getNodeExecutionState: () => ({ status: 'ActionNeeded' as string }), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; + +// ============================================================================ +// Stacked Treatment Story +// ============================================================================ + +const stackedManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = { + categories: [ + ...allCategoryManifests, + { + id: 'agents', + name: 'Agents', + sortOrder: 1, + color: '#7c3aed', + colorDark: '#8b5cf6', + icon: 'robot', + tags: [], + }, + ], + nodes: [ + ...allNodeManifests, + { + nodeType: 'uipath.agent.drillable', + version: '1.0.0', + category: 'agents', + tags: ['agent'], + sortOrder: 1, + drillable: true, + display: { + label: 'Drillable Agent', + icon: 'agent', + shape: 'square', + }, + handleConfiguration: [ + { + position: 'left', + handles: [{ id: 'input', type: 'target', handleType: 'input' }], + }, + { + position: 'right', + handles: [{ id: 'output', type: 'source', handleType: 'output' }], + }, + ], + }, + ], +}; + +function StackedTreatmentStory() { + const initialNodes = useMemo[]>( + () => [ + createNode({ + id: 'plain', + type: 'uipath.blank-node', + position: { x: 96, y: 200 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: 'Plain', subLabel: 'No treatment', shape: 'square' }, + }, + }), + createNode({ + id: 'drillable', + type: 'uipath.agent.drillable', + position: { x: 320, y: 200 }, + data: { + nodeType: 'uipath.agent.drillable', + version: '1.0.0', + display: { label: 'Drillable', subLabel: 'manifest.drillable', shape: 'square' }, + }, + }), + createNode({ + id: 'collapsed', + type: 'uipath.blank-node', + position: { x: 544, y: 200 }, + data: { + nodeType: 'uipath.blank-node', + version: '1.0.0', + display: { label: 'Collapsed', subLabel: 'data.isCollapsed', shape: 'square' }, + isCollapsed: true, + }, + }), + ], + [] + ); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + + + ); +} + +export const StackedTreatment: Story = { + name: 'Stacked Treatment', + decorators: [ + (Story) => ( + + + + ), + ], + render: () => , +}; + +// ============================================================================ +// canvasLabel Resolution Story +// ============================================================================ + +const canvasLabelManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = { + categories: [ + ...allCategoryManifests, + { + id: 'communications', + name: 'Communications', + sortOrder: 99, + color: '#3b82f6', + colorDark: '#60a5fa', + icon: 'agent', + tags: [], + }, + ], + nodes: [ + ...allNodeManifests, + { + nodeType: 'uipath.send-outlook-email', + version: '1.0.0', + category: 'communications', + tags: ['email', 'outlook'], + sortOrder: 1, + display: { + label: 'Send Outlook 365 Email', + canvasLabel: 'Send Email', + icon: 'agent', + shape: 'rectangle', + }, + handleConfiguration: [ + { position: 'left', handles: [{ id: 'input', type: 'target', handleType: 'input' }] }, + { position: 'right', handles: [{ id: 'output', type: 'source', handleType: 'output' }] }, + ], + }, + { + nodeType: 'uipath.long-decision', + version: '1.0.0', + category: 'communications', + tags: ['decision'], + sortOrder: 2, + display: { + label: 'Long Decision Without canvasLabel', + icon: 'agent', + shape: 'rectangle', + }, + handleConfiguration: [ + { position: 'left', handles: [{ id: 'input', type: 'target', handleType: 'input' }] }, + { position: 'right', handles: [{ id: 'output', type: 'source', handleType: 'output' }] }, + ], + }, + ], +}; + +function CanvasLabelStory() { + const initialNodes = useMemo[]>( + () => [ + createNode({ + id: 'with-canvaslabel', + type: 'uipath.send-outlook-email', + position: { x: 96, y: 160 }, + data: { + nodeType: 'uipath.send-outlook-email', + version: '1.0.0', + display: { shape: 'rectangle', subLabel: 'manifest.canvasLabel wins' }, + }, + }), + createNode({ + id: 'without-canvaslabel', + type: 'uipath.long-decision', + position: { x: 96, y: 320 }, + data: { + nodeType: 'uipath.long-decision', + version: '1.0.0', + display: { + shape: 'rectangle', + subLabel: 'falls back to manifest.label since canvasLabel is not defined', + }, + }, + }), + createNode({ + id: 'instance-rename', + type: 'uipath.send-outlook-email', + position: { x: 96, y: 480 }, + data: { + nodeType: 'uipath.send-outlook-email', + version: '1.0.0', + display: { + shape: 'rectangle', + canvasLabel: 'Notify Ops Team', + subLabel: 'instance.canvasLabel overrides manifest.canvasLabel', + }, + }, + }), + ], + [] + ); + const { canvasProps } = useCanvasStory({ initialNodes }); + + return ( + + + + + instance.label > manifest.canvasLabel > manifest.label.' + } + /> + + ); +} + +export const CanvasLabel: Story = { + name: 'canvasLabel resolution', + decorators: [ + (Story) => ( + + + + ), + ], + render: () => , +}; + +export const ValidationStates: Story = { + name: 'Validation States', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: () => undefined, + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: (elementId: string) => { + const severity = elementId.split('-').pop() as string; + if (!['WARNING', 'ERROR', 'CRITICAL'].includes(severity)) return undefined; + return { + validationStatus: severity as ValidationErrorSeverity, + validationError: { + code: `VALIDATION_${severity}`, + message: validationMessages[severity] ?? `Validation ${severity.toLowerCase()}`, + description: validationMessages[severity] ?? `Validation ${severity.toLowerCase()}`, + severity: severity as ValidationErrorSeverity, + }, + }; + }, + }, + }), + ], + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx new file mode 100644 index 000000000..2d8b4a532 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeV2.stories.tsx @@ -0,0 +1,2348 @@ +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 { cn } from '@uipath/apollo-wind'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAddNodeOnConnectEnd, useCanvasEvent } from '../../hooks'; +import { + createNode, + StoryInfoPanel, + useCanvasStory, + withCanvasProviders, +} from '../../storybook-utils'; +import { DefaultCanvasTranslations } from '../../types'; +import { ElementStatusValues } from '../../types/execution'; +import type { CanvasHandleActionEvent } from '../../utils'; +import { + BreakpointIndicator, + ExecutionStartPointIndicator, + ExecutionStatusIndicator, + SquareDashedIndicator, +} from '../../utils/adornment-resolver'; +import { CanvasIcon } from '../../utils/icon-registry'; +import { removePreviewFromReactFlow } from '../../utils/createPreviewNode'; +import { snapToGrid } from '../../utils/NodeUtils'; +import { AddNodeManager } from '../AddNodePanel'; +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 = { + title: 'Components/LoopNode V2', + parameters: { layout: 'fullscreen' }, + decorators: [withCanvasProviders()], +}; + +export default meta; +type Story = StoryObj; + +const LOOP_TYPE = 'uipath.control-flow.foreach'; +const ACTIVITY_TYPE = 'uipath.blank-node'; +const STORY_LOOP_START_HANDLE_ID = 'start'; +const STORY_LOOP_CONTINUE_HANDLE_ID = 'continue'; +const STORY_LOOP_SUCCESS_HANDLE_ID = 'success'; + +const snapPoint = (point: { x: number; y: number }) => ({ + x: snapToGrid(point.x), + y: snapToGrid(point.y), +}); + +const snapSize = (size: { width: number; height: number }) => ({ + width: snapToGrid(size.width), + height: snapToGrid(size.height), +}); + +function createLoopContainerNode( + id: string, + position: { x: number; y: number }, + size: { width: number; height: number }, + options?: { parentId?: string; selected?: boolean; data?: LoopNodeData } +): Node { + const snappedSize = snapSize(size); + const display = { + ...options?.data?.display, + shape: 'container' as const, + }; + + return { + id, + type: LOOP_TYPE, + position: snapPoint(position), + parentId: options?.parentId, + selected: options?.selected ?? false, + data: { + ...options?.data, + display, + }, + style: { width: snappedSize.width, height: snappedSize.height }, + }; +} + +function createActivityNode( + id: string, + label: string, + position: { x: number; y: number }, + options?: { parentId?: string; subLabel?: string | null } +): Node { + const node = createNode({ + id, + type: ACTIVITY_TYPE, + position: snapPoint(position), + display: options?.subLabel ? { label, subLabel: options.subLabel } : { label }, + }); + + if (options?.parentId) { + return { + ...node, + parentId: options.parentId, + }; + } + + return node; +} + +interface AutoPreviewSource { + nodeId: string; + handleId: string; + position?: Position; +} + +interface StoryInfo { + title: string; + description: string; +} + +interface LoopCanvasStoryProps { + initialNodes: Node[]; + initialEdges: Edge[]; + autoPreviewSource?: AutoPreviewSource; + storyInfo: StoryInfo; +} + +function LoopCanvasStory({ + initialNodes, + initialEdges, + autoPreviewSource, + storyInfo, +}: LoopCanvasStoryProps) { + const reactFlow = useReactFlow(); + const handleAddNodeOnConnectEnd = useAddNodeOnConnectEnd(); + const autoPreviewedRef = useRef(false); + + const { canvasProps, nodeTypeRegistry } = useCanvasStory({ + initialNodes, + initialEdges, + }); + + const loopPreviewOptions = useMemo( + () => ({ + getManifestForNode: (node: Node) => + node.type ? nodeTypeRegistry.getManifest(node.type) : undefined, + }), + [nodeTypeRegistry] + ); + + useEffect(() => { + if (!autoPreviewSource || autoPreviewedRef.current) return; + + const frame = window.requestAnimationFrame(() => { + if (autoPreviewedRef.current) return; + + autoPreviewedRef.current = true; + createAddNodePreview( + autoPreviewSource.nodeId, + autoPreviewSource.handleId, + reactFlow, + autoPreviewSource.position ?? Position.Right, + 'source', + [], + loopPreviewOptions + ); + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [autoPreviewSource, loopPreviewOptions, reactFlow]); + + const handleHandleAction = useCallback( + (event: CanvasHandleActionEvent) => { + const { handleId, nodeId, position, handleType } = event; + if (!handleId || !nodeId) return; + + createAddNodePreview( + nodeId, + handleId, + reactFlow, + position as Position, + handleType === 'input' ? 'target' : 'source', + [], + loopPreviewOptions + ); + }, + [loopPreviewOptions, reactFlow] + ); + + useCanvasEvent('handle:action', handleHandleAction); + + const handlePaneClick = useCallback(() => { + removePreviewFromReactFlow(reactFlow); + }, [reactFlow]); + + return ( + + + + + + + + ); +} + +function DefaultStory() { + const initialNodes = useMemo( + () => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 256 }), + createLoopContainerNode( + 'loop-1', + { x: 224, y: 128 }, + { width: 704, height: 368 }, + { + selected: true, + data: { display: { label: 'For Each claim' } }, + } + ), + createActivityNode('child-1', 'Analyze claims', { x: 160, y: 96 }, { parentId: 'loop-1' }), + createActivityNode('child-2', 'Write outcome', { x: 432, y: 96 }, { parentId: 'loop-1' }), + createActivityNode('egress', 'Publish results', { x: 1024, y: 256 }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + { + id: 'ingress-loop', + source: 'ingress', + sourceHandle: 'output', + target: 'loop-1', + targetHandle: 'input', + }, + { + id: 'loop-child-1', + source: 'loop-1', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'child-1', + targetHandle: 'input', + }, + { + id: 'child-1-child-2', + source: 'child-1', + sourceHandle: 'output', + target: 'child-2', + targetHandle: 'input', + }, + { + id: 'child-2-loop', + source: 'child-2', + sourceHandle: 'output', + target: 'loop-1', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'loop-egress', + source: 'loop-1', + sourceHandle: 'success', + target: 'egress', + targetHandle: 'input', + }, + ], + [] + ); + + return ( + + ); +} + +function NestedOuterOutputInsertStory() { + const initialNodes = useMemo( + () => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 288 }), + createLoopContainerNode( + 'outer-loop', + { x: 192, y: 80 }, + { width: 1040, height: 496 }, + { + selected: true, + data: { display: { label: 'For Each claim' } }, + } + ), + createLoopContainerNode( + 'inner-loop', + { x: 128, y: 112 }, + { width: 496, height: 304 }, + { + parentId: 'outer-loop', + data: { display: { label: 'For Each attachment' } }, + } + ), + createActivityNode( + 'inner-child', + 'Classify attachment', + { x: 176, y: 112 }, + { parentId: 'inner-loop' } + ), + createActivityNode( + 'review', + 'Review finding', + { x: 720, y: 216 }, + { parentId: 'outer-loop' } + ), + createActivityNode('egress', 'Publish results', { x: 1296, y: 288 }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + { + id: 'ingress-outer-loop', + source: 'ingress', + sourceHandle: 'output', + target: 'outer-loop', + targetHandle: 'input', + }, + { + id: 'outer-loop-inner-loop', + source: 'outer-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-loop', + targetHandle: 'input', + }, + { + id: 'inner-loop-inner-child', + source: 'inner-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-child', + targetHandle: 'input', + }, + { + id: 'inner-child-inner-loop', + source: 'inner-child', + sourceHandle: 'output', + target: 'inner-loop', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'inner-loop-review', + source: 'inner-loop', + sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, + target: 'review', + targetHandle: 'input', + }, + { + id: 'review-outer-loop', + source: 'review', + sourceHandle: 'output', + target: 'outer-loop', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'outer-loop-egress', + source: 'outer-loop', + sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, + target: 'egress', + targetHandle: 'input', + }, + ], + [] + ); + + return ( + + ); +} + +function NestedOuterOutputAppendStory() { + const initialNodes = useMemo( + () => [ + createActivityNode('ingress', 'Load records', { x: 32, y: 272 }), + createLoopContainerNode( + 'outer-loop', + { x: 224, y: 96 }, + { width: 896, height: 448 }, + { + selected: true, + data: { display: { label: 'For Each claim' } }, + } + ), + createLoopContainerNode( + 'inner-loop', + { x: 160, y: 112 }, + { width: 544, height: 304 }, + { + parentId: 'outer-loop', + data: { display: { label: 'For Each attachment' } }, + } + ), + createActivityNode( + 'inner-child', + 'Classify attachment', + { x: 176, y: 112 }, + { parentId: 'inner-loop' } + ), + createActivityNode('egress', 'Publish results', { x: 1216, y: 272 }), + ], + [] + ); + + const initialEdges = useMemo( + () => [ + { + id: 'ingress-outer-loop', + source: 'ingress', + sourceHandle: 'output', + target: 'outer-loop', + targetHandle: 'input', + }, + { + id: 'outer-loop-inner-loop', + source: 'outer-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-loop', + targetHandle: 'input', + }, + { + id: 'inner-loop-inner-child', + source: 'inner-loop', + sourceHandle: STORY_LOOP_START_HANDLE_ID, + target: 'inner-child', + targetHandle: 'input', + }, + { + id: 'inner-child-inner-loop', + source: 'inner-child', + sourceHandle: 'output', + target: 'inner-loop', + targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID, + }, + { + id: 'outer-loop-egress', + source: 'outer-loop', + sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, + target: 'egress', + targetHandle: 'input', + }, + ], + [] + ); + + return ( + + ); +} + +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 ( + + + + + + + ); +} + +// ============================================================================ +// Anatomy: LoopNode — full-page documentation layout +// ============================================================================ + +const LOOP_SLOT_DOCS = [ + { slot: 'topLeft', dot: 'bg-red-500', rule: 'Breakpoint', detail: 'Debug mode — pauses execution at this node.' }, + { slot: 'topRight', dot: 'bg-emerald-500', rule: 'Status › Validation error › Warning', detail: 'First matching state wins.' }, + { slot: 'bottomLeft', dot: 'bg-blue-500', rule: 'Execution start point', detail: 'Marks the entry node for the current run.' }, + { slot: 'bottomRight', dot: 'bg-amber-500', rule: 'Output pinned', detail: 'Shown when the node output is mocked/pinned. LoopNode does not carry a loop count badge — that appears on child nodes.' }, +] as const; + +const LOOP_HANDLE_DOCS = [ + { handle: 'input', side: 'Left outer', boundary: 'outer', description: 'Incoming connection — the edge entering the loop.' }, + { handle: 'success', side: 'Right outer', boundary: 'outer', description: 'Loop completed — exits when all iterations finish.' }, + { handle: 'start', side: 'Inner top', boundary: 'inner', description: 'First activity inside the loop body connects here.' }, + { handle: 'continue', side: 'Inner bottom', boundary: 'inner', description: 'Last activity loops back here to begin the next iteration.' }, +] as const; + +const PICKER_DOCS = [ + { ctrl: '●', label: 'Status dot', desc: 'Colored dot indicating the current iteration\'s outcome (green = Completed, red = Failed, amber = In Progress, purple = Paused, grey = Cancelled). Only shown when per-iteration status data is available.' }, + { ctrl: '|◄', label: 'Jump first', desc: 'Jumps to iteration 1.' }, + { ctrl: '◄', label: 'Step back', desc: 'Moves one iteration back. Disabled at the start.' }, + { ctrl: '[index / total]', label: 'Position', desc: 'Click the index number to activate an inline input — type a target, Enter to commit, Escape to cancel.' }, + { ctrl: '►', label: 'Step forward', desc: 'Moves one iteration forward. Disabled at the end.' }, + { ctrl: '►|', label: 'Jump last', desc: 'Jumps to the final iteration.' }, + { ctrl: 'All', label: 'All toggle', desc: 'Collapses into an aggregate view. With status data shows ✓ completed ✗ failed; otherwise Σ total. Click again to return to individual view.' }, + { ctrl: '⊕', label: 'Jump to failed', desc: 'Visible only when at least one iteration has a Failed status and the loop itself has not globally failed. Jumps directly to the first failed iteration.' }, +] as const; + +// Demo data for anatomy live demo +const DEMO_ITERATION_STATUSES = new Map([ + [0, 'Completed'], + [1, 'Completed'], + [2, 'Failed'], + [3, 'Completed'], + [4, 'InProgress'], +]); +const DEMO_TOTAL = 8; + +function AnatomyStory() { + const [demoIndex, setDemoIndex] = useState(0); + const [demoIsAll, setDemoIsAll] = useState(false); + const [demoIndexB, setDemoIndexB] = useState(0); + const [demoIsAllB, setDemoIsAllB] = useState(true); + + // State for the "in context" adornment demos + const [ctxIndex, setCtxIndex] = useState(1); + const [ctxIsAll, setCtxIsAll] = useState(false); + const [ctxIndexB, setCtxIndexB] = useState(1); + const [ctxIsAllB, setCtxIsAllB] = useState(false); + + // State for the responsive behavior section demos + const [respIndex, setRespIndex] = useState(1); + const [respIsAll, setRespIsAll] = useState(false); + + return ( +
+
+ + {/* ── Page header ── */} +
+

LoopNode Anatomy

+

+ LoopNode is a resizable container with a header bar, a dashed body frame for child nodes, + and four corner adornment slots. It exposes four handles — two outer edge handles for the + process flow and two inner handles for the loop body. +

+
+ +
+ + {/* ── Container Structure ── */} +
+
+

Container Structure

+

+ Three layers compose the loop container. The header's negative bottom margin creates a + flush visual join between the header and the body frame. +

+
+ + {/* Loop node mock */} +
+
+
+
+
+ + For Each claim +
+ + + Sequential + +
+
+ child nodes +
+
+
+
+ +
+ {[ + { label: 'Outer container', token: 'rounded-[20px] border', desc: 'Receives hover, selected, and drag states via outline and shadow overrides.' }, + { label: 'Header', token: 'bg-surface-overlay -mb-2.5', desc: 'Negative bottom margin creates the overlap with the body frame.' }, + { label: 'Body frame', token: 'border-dashed rounded-xl m-2.5', desc: 'Child nodes live inside here. Drives minimum container resize calculations.' }, + ].map(({ label, token, desc }) => ( +
+
{label}
+ {token} +
{desc}
+
+ ))} +
+
+ +
+ + {/* ── Adornment Slots ── */} +
+
+

Adornment Slots

+

+ Four 20×20 px slots at each corner of the outer container. Slots are only visible + when execution, debug, or validation state is active — they are hidden at rest. + Two placement options are under consideration. +

+
+ + {/* Option A / Option B diagram comparison */} +
+ + {/* Option A — fully inside */} +
+
+

Option A — Inside border

+
    +
  • Never overflows node bounds — safe for dense canvas layouts where nodes sit close together.
  • +
  • Predictable hit area — all interactions stay within the node boundary.
  • +
  • Competes with header content near corners, reducing usable space.
  • +
  • Badge can visually merge with the node background, reducing contrast.
  • +
+
+
+
+ {/* Left callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot }) => ( +
+ {slot} +
+
+
+
+
+ ))} +
+ {/* Full-header node mock */} +
+
+
+
+ + For Each item +
+
+ { setCtxIsAll(false); setCtxIndex(i); }, isAll: ctxIsAll, onAllChange: setCtxIsAll, iterationStatuses: new Map([[0,'Completed'],[1,'InProgress']]), overallStatus: undefined }} /> + + Sequential + +
+
+
+ {/* Adornments — inset 6px */} +
+
+
+
+
+ {/* Right callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot }) => ( +
+
+
+
+
+ {slot} +
+ ))} +
+
+
+
+ + {/* Option B — on the border (50/50) */} +
+
+

Option B — On border

+
    +
  • Familiar notification badge pattern — immediately recognisable to most users.
  • +
  • Pops clearly against both the node background and the canvas — stronger contrast.
  • +
  • Extends slightly outside node bounds — may overlap adjacent nodes or edges at tight spacing.
  • +
  • Canvas layout needs to account for the overflow margin around each node.
  • +
+
+
+
+ {/* Left callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 === 0).map(({ slot, dot }) => ( +
+ {slot} +
+
+
+
+
+ ))} +
+ {/* Full-header node mock */} +
+
+
+
+ + For Each item +
+
+ { setCtxIsAllB(false); setCtxIndexB(i); }, isAll: ctxIsAllB, onAllChange: setCtxIsAllB, iterationStatuses: new Map([[0,'Completed'],[1,'InProgress']]), overallStatus: undefined }} /> + + Sequential + +
+
+
+ {/* Adornments — on border -2px */} +
+
+
+
+
+ {/* Right callouts */} +
+ {LOOP_SLOT_DOCS.filter((_, i) => i % 2 !== 0).map(({ slot, dot }) => ( +
+
+
+
+
+ {slot} +
+ ))} +
+
+
+
+ +
+ + {/* Key trade-off callout */} +
+

Canvas density — Option A never overflows the node boundary, making it safer when nodes are tightly packed. Option B extends slightly outside, so the canvas layout needs a small reserved margin around each node.

+

Header crowding — Option A places the topRight badge inside the header, competing with the iteration picker and Sequential badge for horizontal space (the component compensates with extra padding). Option B moves the badge onto the border, reducing that internal tension and keeping the header content area cleaner — which directly addresses the concern that the header feels full.

+
+ +
+ + + + + + + + + + {LOOP_SLOT_DOCS.map(({ slot, dot, rule, detail }, i) => ( + + + + + + ))} + +
SlotPriority chainNotes
+
+
+ {slot} +
+
{rule}{detail}
+
+
+ +
+ + {/* ── Handles ── */} +
+
+

Handles

+

+ Two outer handles carry the main process flow; two inner handles define the loop body + entry and continuation points. +

+
+
+ + + + + + + + + + + {LOOP_HANDLE_DOCS.map(({ handle, side, boundary, description }, i) => ( + + + + + + + ))} + +
Handle IDPositionBoundaryDescription
+ {handle} + {side} + + {boundary} + + {description}
+
+
+ +
+ + {/* ── V2 Iterations ── */} +
+
+

V2 Iterations

+

+ Improvements introduced in the V2 prototype. All changes are scoped to LoopNode V2 + only — the original LoopNode story is unaffected. +

+
+ + {/* Subsection: Execution Count */} +
+

Execution Count

+

+ How many times the loop body has run. This count surfaces in two places at once — + as the denominator in the iteration picker on the loop itself, and as a{' '} + ↻ N{' '} + badge on each child node inside the loop. +

+ +
+
+ +
+
Loop — iteration picker
+
+ 2 / + 5 +
+

+ The denominator 5 is the loop's + execution count — how many iterations have been run or are planned for this run. +

+
+ +
+
Child node — loop count badge
+
+ + 5 +
+

+ Each child node shows the same count in its{' '} + bottomRight{' '} + adornment slot. When the loop finishes, both numbers match. +

+
+ +
+
+ +
+ + + + + + + + + + {([ + { prop: 'total (N)', loc: 'Picker denominator', meaning: 'Total iterations the loop will run. Provided by the execution runtime.' }, + { prop: 'activeIndex (k)', loc: 'Picker numerator', meaning: 'The iteration currently being viewed, 1-based (displayed as k = activeIndex + 1).' }, + { prop: 'count', loc: 'Child node ↻ N badge', meaning: 'How many times this child node has executed so far. Equals total when the loop completes.' }, + ] as const).map(({ prop, loc, meaning }, i, arr) => ( + + + + + + ))} + +
PropertyLocationMeaning
{prop}{loc}{meaning}
+
+
+ + {/* Side-by-side comparison */} +
+ + {/* Column A */} +
+
+
+

Option A

+
+

+ "All" as a separate chip alongside a compound picker with first / prev / next / + last buttons. All controls visible at all times. +

+
+
+ { setDemoIsAll(false); setDemoIndex(i); }, + isAll: demoIsAll, + onAllChange: setDemoIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+

+ Compound Picker — separate chip + for All, four navigation buttons, click-to-type fraction. +

+
+ + {/* Column B */} +
+
+
+

Option B

+
+

+ "All" becomes the left segment of a single pill. First and last jump buttons + removed — click-to-type handles large jumps. +

+
+
+ { setDemoIsAllB(false); setDemoIndexB(i); }, + isAll: demoIsAllB, + onAllChange: setDemoIsAllB, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+

+ Unified Segmented Pill — one + cohesive control, lower visual weight, All integrated as a segment. +

+
+ +
+ + {/* Tradeoff comparison table — full width */} +
+ + + + + + + + + + {([ + { aspect: '"All" placement', a: 'Separate chip', b: 'Left segment of pill' }, + { aspect: 'First / last jump', a: '|◄ and ►| buttons', b: 'Removed — use click-to-type' }, + { aspect: 'Element count', a: '3 elements + ⊕', b: '1 pill + ⊕' }, + { aspect: 'Discoverability', a: 'High — all controls shown', b: 'Medium — no first/last shortcut' }, + { aspect: 'Visual weight', a: 'Higher', b: 'Lower' }, + ] as const).map(({ aspect, a, b }, i, arr) => ( + + + + + + ))} + +
AspectOption AOption B
{aspect}{a}{b}
+
+ + {/* Option A control reference */} +
+

Option A — Control Reference

+
+ + + + + + + + + + {PICKER_DOCS.map(({ ctrl, label, desc }, i) => ( + + + + + + ))} + +
ControlLabelBehaviour
{ctrl}{label}{desc}
+
+
+ +
+ +
+ + {/* ── Responsive Behavior ── */} +
+
+

Responsive Behavior

+

+ The iteration picker adapts to available header width through three size tiers. As a loop + node is resized smaller — or deeply nested loops reduce the visible header width — the + picker progressively removes controls to remain usable without overflowing. +

+

+ Note: LoopNode.tsx{' '} + does not yet implement this logic. These tiers are the intended V2 behavior — to be wired + in once the picker approach is approved. +

+
+ + {/* Breakpoint table */} +
+ + + + + + + + + + {([ + { width: '≥ 400 px', tier: 'full', controls: 'All chip · |◄ ◄ k/N ► ►| · crosshair' }, + { width: '260–399 px', tier: 'compact', controls: 'All chip · k/N (click-to-type) · crosshair' }, + { width: '< 260 px', tier: 'minimal', controls: 'k/N count chip only (read-only)' }, + ] as const).map(({ width, tier, controls }, i, arr) => ( + + + + + + ))} + +
Node widthSize tierControls visible
{width} + {tier} + {controls}
+
+ + {/* Three interactive picker mocks — share a single state so you can compare tiers */} +
+ + {/* Wide — full */} +
+
+
+ full +

Wide — ≥ 400 px

+
+

All navigation controls plus first/last jump buttons.

+
+
+ { setRespIsAll(false); setRespIndex(i); }, + isAll: respIsAll, + onAllChange: setRespIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ + {/* Medium — compact */} +
+
+
+ compact +

Medium — 260–399 px

+
+

Arrows removed; click the fraction to jump directly.

+
+
+ { setRespIsAll(false); setRespIndex(i); }, + isAll: respIsAll, + onAllChange: setRespIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ + {/* Narrow — minimal */} +
+
+
+ minimal +

Narrow — < 260 px

+
+

Count chip only — read-only indicator, no navigation.

+
+
+ { setRespIsAll(false); setRespIndex(i); }, + isAll: respIsAll, + onAllChange: setRespIsAll, + iterationStatuses: DEMO_ITERATION_STATUSES, + }} + /> +
+
+ +
+ +
+

Why three tiers? — Deeply nested loops reduce the horizontal space available to the iteration picker. Rather than letting the control overflow or truncate awkwardly, we progressively remove less-critical actions: first/last jump buttons go first (replaced by click-to-type), then navigation arrows entirely. The count chip in minimal mode keeps the iteration position visible at a glance without requiring any interaction.

+

Implementation note — The size tier is computed from props.width in the canvas node wrapper (LoopExecutionCanvasNodePill). The navigator components themselves are unaware of the canvas — they accept an explicit size prop, keeping them independently testable.

+
+ +
+ +
+
+ ); +} + +// ============================================================================ +// V2: Compound Iteration Picker + "All" aggregate toggle +// Lives entirely in this story file — no shared component changes. +// LoopNode is rendered with iterationState={undefined} (native nav hidden) +// and the V2 nav is overlaid absolutely into the header area. +// ============================================================================ + +interface LoopIterationStateV2 { + activeIndex: number; + total: number; + onActiveIndexChange?: (nextIndex: number) => void; + disabled?: boolean; + isAll: boolean; + onAllChange: (isAll: boolean) => void; + iterationStatuses?: Map; + overallStatus?: ElementStatusValues; +} + +function stopV2Event(e: React.SyntheticEvent) { + e.stopPropagation(); +} + +function V2NavButton({ + onClick, + disabled, + ariaLabel, + children, +}: { + onClick: (e: React.MouseEvent) => void; + disabled: boolean; + ariaLabel: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function getIterationStatusColor(status: string | undefined): string { + switch (status) { + case 'Completed': return '#22c55e'; + case 'Failed': return '#ef4444'; + case 'InProgress': return '#f59e0b'; + case 'Paused': return '#a855f7'; + case 'Cancelled': return '#94a3b8'; + default: return 'currentColor'; + } +} + +function IterationNavigatorV2({ state, size = 'full' }: { state: LoopIterationStateV2; size?: 'full' | 'compact' | 'minimal' }) { + const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses, overallStatus } = state; + 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)); + + // Derived status info from per-iteration status map + const currentStatus = iterationStatuses?.get(activeIndex); + const firstFailedIndex = iterationStatuses + ? [...iterationStatuses.entries()].find(([, s]) => s === 'Failed')?.[0] + : undefined; + const completedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === 'Completed').length + : undefined; + const failedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === 'Failed').length + : 0; + + const handleFirst = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll) onActiveIndexChange?.(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 handleLast = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll) onActiveIndexChange?.(total - 1); + }; + + 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 toggleAll = (e: React.MouseEvent) => { + e.stopPropagation(); + onAllChange(!isAll); + }; + + const handleJumpToFailed = (e: React.MouseEvent) => { + e.stopPropagation(); + if (firstFailedIndex !== undefined) onActiveIndexChange?.(firstFailedIndex); + }; + + const canGoFirst = canInteract && !isAll && activeIndex > 0; + const canGoPrev = canInteract && !isAll && activeIndex > 0; + const canGoNext = canInteract && !isAll && activeIndex < total - 1; + const canGoLast = canInteract && !isAll && activeIndex < total - 1; + + if (size === 'minimal') { + return ( +
+
+ {isAll ? ( + completedCount !== undefined ? ( + <> + ✓{completedCount} + {failedCount > 0 && ✗{failedCount}} + + ) : ( + <>Σ{total} + ) + ) : ( + <> + {currentStatus && } + {visibleIndex} + / + {total} + + )} +
+
+ ); + } + + return ( +
+ {/* All toggle chip */} + + + {isAll ? ( + /* Aggregate badge — clickable to exit All mode */ + + ) : ( + /* Compound picker: |◄ ◄ [index/total] ► ►| */ +
+ + Iteration {visibleIndex} of {total} + + + {size === 'full' && ( + + + + )} + + + + + {/* Editable fraction with status dot — click index number to jump to a specific iteration */} + + {isEditing ? ( + <> + setInputValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleInputKeyDown} + onPointerDown={stopV2Event} + className="w-7 appearance-none bg-transparent text-center text-[11px] font-semibold leading-4 outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none border-b border-foreground-accent" + /> + / + {total} + + ) : ( + <> + {currentStatus && ( + + )} + {visibleIndex} + / + {total} + + )} + + + + + + {size === 'full' && ( + + + + )} +
+ )} + + {/* Jump-to-failed — hidden when the loop itself is already in Failed state (adornment covers it) */} + {firstFailedIndex !== undefined && !isAll && canInteract && overallStatus !== ElementStatusValues.Failed && ( + + )} +
+ ); +} + +// ============================================================================ +// Option B: Unified Segmented Pill +// All is the leftmost segment inside a single pill — no first/last jump buttons. +// Click-to-type on the fraction handles large jumps. +// ============================================================================ + +function IterationNavigatorPill({ state, size = 'full' }: { state: LoopIterationStateV2; size?: 'full' | 'compact' | 'minimal' }) { + const { activeIndex, total, onActiveIndexChange, disabled, isAll, onAllChange, iterationStatuses, overallStatus } = state; + 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 === 'Failed')?.[0] + : undefined; + const completedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === 'Completed').length + : undefined; + const failedCount = iterationStatuses + ? [...iterationStatuses.values()].filter(s => s === '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; + + if (size === 'minimal') { + return ( +
+
+ {isAll ? ( + completedCount !== undefined ? ( + <> + ✓{completedCount} + {failedCount > 0 && ✗{failedCount}} + + ) : ( + <>Σ{total} + ) + ) : ( + <> + {currentStatus && } + {visibleIndex} + / + {total} + + )} +
+
+ ); + } + + 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={stopV2Event} + 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 !== ElementStatusValues.Failed && ( + + )} +
+ ); +} + +type LoopExecutionNodeDataV2 = LoopNodeData & { + initialIndex: number; + total: number; + interactive?: boolean; + iterationStatuses?: Map; + status?: ElementStatusValues; +}; + +// Realistic per-iteration status data for each V2 execution demo node +const LOOP_EXECUTION_ITERATION_STATUSES = new Map>([ + ['loop-completed', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], + ['loop-running', new Map([[0, 'Completed'], [1, 'InProgress']])], + ['loop-paused', new Map([[0, 'Completed'], [1, 'Paused']])], + ['loop-failed', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Failed']])], + ['loop-cancelled', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Cancelled']])], + ['loop-parallel', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], + ['loop-label-only', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], + ['loop-clamped', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], +]); + +function createExecutionStateGridV2(): Node[] { + return LOOP_EXECUTION_CASES.map(({ id, label, initialIndex, total, parallel, interactive, status }, 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, + iterationStatuses: LOOP_EXECUTION_ITERATION_STATUSES.get(id), + status, + }, + style: LOOP_EXECUTION_SIZE, + }; + }); +} + +// V2 HEADER_OVERLAY_TOP: aligns with header's pt-2.5 (10px) to vertically center in the h-6 content row +// V2 HEADER_OVERLAY_RIGHT: clears the Sequential/Parallel badge (~105px) + gap-2 (8px) + px-3.5 (14px) +const V2_OVERLAY_TOP = 10; +const V2_OVERLAY_RIGHT = 130; + +function LoopExecutionCanvasNodeV2(props: NodeProps>) { + const { data } = props; + const [activeIndex, setActiveIndex] = useState( + Math.max(0, Math.min(data.total - 1, data.initialIndex)) + ); + const [isAll, setIsAll] = useState(false); + + useEffect(() => { + setActiveIndex(Math.max(0, Math.min(data.total - 1, data.initialIndex))); + setIsAll(false); + }, [data.initialIndex, data.total]); + + const iterationState: LoopIterationStateV2 = { + activeIndex, + total: data.total, + onActiveIndexChange: + data.interactive === false + ? undefined + : (i) => { + setIsAll(false); + setActiveIndex(i); + }, + isAll, + onAllChange: setIsAll, + iterationStatuses: data.iterationStatuses, + overallStatus: data.status, + }; + + const navSize: 'full' | 'compact' | 'minimal' = + (props.width ?? 0) >= 400 ? 'full' : + (props.width ?? 0) >= 260 ? 'compact' : 'minimal'; + + return ( +
+ {/* Render LoopNode without its native iterator — we overlay V2 nav instead */} + +
+ +
+
+ ); +} + +const LOOP_EXECUTION_NODE_TYPES_V2 = { + [LOOP_TYPE]: LoopExecutionCanvasNodeV2, +}; + +function LoopExecutionCanvasNodePill(props: NodeProps>) { + const { data } = props; + const [activeIndex, setActiveIndex] = useState( + Math.max(0, Math.min(data.total - 1, data.initialIndex)) + ); + const [isAll, setIsAll] = useState(true); + + useEffect(() => { + setActiveIndex(Math.max(0, Math.min(data.total - 1, data.initialIndex))); + setIsAll(true); + }, [data.initialIndex, data.total]); + + const iterationState: LoopIterationStateV2 = { + activeIndex, + total: data.total, + onActiveIndexChange: + data.interactive === false + ? undefined + : (i) => { + setIsAll(false); + setActiveIndex(i); + }, + isAll, + onAllChange: setIsAll, + iterationStatuses: data.iterationStatuses, + overallStatus: data.status, + }; + + const navSize: 'full' | 'compact' | 'minimal' = + (props.width ?? 0) >= 400 ? 'full' : + (props.width ?? 0) >= 260 ? 'compact' : 'minimal'; + + return ( +
+ +
+ +
+
+ ); +} + +const LOOP_EXECUTION_NODE_TYPES_PILL = { + [LOOP_TYPE]: LoopExecutionCanvasNodePill, +}; + +function ExecutionStatesV2Story() { + const initialNodes = useMemo(() => createExecutionStateGridV2(), []); + const { canvasProps } = useCanvasStory({ + initialNodes, + additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_V2, + }); + + return ( + + + + + + + ); +} + +// ============================================================================ +// Loop Node V2 Demo — 4-level nested loop, 3 activity nodes per loop +// Layout: inner loop at top of each body, 3 nodes in a row below. +// Flow per level: loopN.start → innerLoop → nodeA → nodeB → nodeC → loopN.continue +// ============================================================================ + +// Stable reference: triggers fitView on first load without repositioning nodes +const fitViewOnLoad = async () => {}; + +const V2_DEMO_LOOP_STATUS = new Map([ + ['demo-loop-1', ElementStatusValues.InProgress], + ['demo-loop-2', ElementStatusValues.InProgress], + ['demo-loop-3', ElementStatusValues.InProgress], + ['demo-loop-4', ElementStatusValues.Completed], +]); + +const V2_DEMO_ITERATION_STATUSES = new Map>([ + ['demo-loop-1', new Map([[0, 'Completed'], [1, 'InProgress']])], + ['demo-loop-2', new Map([[0, 'InProgress']])], + ['demo-loop-3', new Map([[0, 'Completed'], [1, 'InProgress']])], + ['demo-loop-4', new Map([[0, 'Completed'], [1, 'Completed'], [2, 'Completed']])], +]); + +function LoopNodeV2DemoStory() { + const reactFlow = useReactFlow(); + const handleAddNodeOnConnectEnd = useAddNodeOnConnectEnd(); + + const initialNodes = useMemo(() => [ + // Start + Finish (canvas level) + createActivityNode('demo-start', 'Load Data', { x: 32, y: 496 }), + createActivityNode('demo-finish', 'Generate Report', { x: 1520, y: 496 }), + + // Loop 1 — For Each Region (outermost, 1200 × 928) + { + id: 'demo-loop-1', + type: LOOP_TYPE, + position: snapPoint({ x: 224, y: 32 }), + data: { + display: { label: 'For Each Region', shape: 'container' as const }, + initialIndex: 1, total: 8, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-1'), + status: ElementStatusValues.InProgress, + }, + style: snapSize({ width: 1200, height: 928 }), + }, + + // Loop 2 — For Each City (inside Loop 1, 1040 × 688) + { + id: 'demo-loop-2', + type: LOOP_TYPE, + position: snapPoint({ x: 80, y: 80 }), + parentId: 'demo-loop-1', + data: { + display: { label: 'For Each City', shape: 'container' as const }, + initialIndex: 0, total: 5, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-2'), + status: ElementStatusValues.InProgress, + }, + style: snapSize({ width: 1040, height: 688 }), + }, + + // Loop 3 — For Each Street (inside Loop 2, 880 × 448) + { + id: 'demo-loop-3', + type: LOOP_TYPE, + position: snapPoint({ x: 80, y: 80 }), + parentId: 'demo-loop-2', + data: { + display: { label: 'For Each Street', shape: 'container' as const }, + initialIndex: 1, total: 3, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-3'), + status: ElementStatusValues.InProgress, + }, + style: snapSize({ width: 880, height: 448 }), + }, + + // Loop 4 — For Each Property (inside Loop 3, 720 × 208, deepest) + { + id: 'demo-loop-4', + type: LOOP_TYPE, + position: snapPoint({ x: 80, y: 80 }), + parentId: 'demo-loop-3', + data: { + display: { label: 'For Each Property', shape: 'container' as const }, + initialIndex: 2, total: 3, + iterationStatuses: V2_DEMO_ITERATION_STATUSES.get('demo-loop-4'), + status: ElementStatusValues.Completed, + }, + style: snapSize({ width: 720, height: 208 }), + }, + + // Loop 4 nodes (y:96 inside Loop 4) + createActivityNode('demo-4a', 'Inspect', { x: 80, y: 96 }, { parentId: 'demo-loop-4' }), + createActivityNode('demo-4b', 'Assess', { x: 272, y: 96 }, { parentId: 'demo-loop-4' }), + createActivityNode('demo-4c', 'Record', { x: 464, y: 96 }, { parentId: 'demo-loop-4' }), + + // Loop 3 nodes (y:336 inside Loop 3, below Loop 4 which ends at y:288) + createActivityNode('demo-3a', 'Validate Street', { x: 80, y: 336 }, { parentId: 'demo-loop-3' }), + createActivityNode('demo-3b', 'Flag Issues', { x: 272, y: 336 }, { parentId: 'demo-loop-3' }), + createActivityNode('demo-3c', 'Log Report', { x: 464, y: 336 }, { parentId: 'demo-loop-3' }), + + // Loop 2 nodes (y:576 inside Loop 2, below Loop 3 which ends at y:528) + createActivityNode('demo-2a', 'Validate City', { x: 80, y: 576 }, { parentId: 'demo-loop-2' }), + createActivityNode('demo-2b', 'Aggregate Data', { x: 272, y: 576 }, { parentId: 'demo-loop-2' }), + createActivityNode('demo-2c', 'Submit City', { x: 464, y: 576 }, { parentId: 'demo-loop-2' }), + + // Loop 1 nodes (y:816 inside Loop 1, below Loop 2 which ends at y:768) + createActivityNode('demo-1a', 'Compile Region', { x: 80, y: 816 }, { parentId: 'demo-loop-1' }), + createActivityNode('demo-1b', 'Audit Region', { x: 272, y: 816 }, { parentId: 'demo-loop-1' }), + createActivityNode('demo-1c', 'Archive Region', { x: 464, y: 816 }, { parentId: 'demo-loop-1' }), + ], []); + + const initialEdges = useMemo(() => [ + // Outer flow + { id: 'e-start-l1', source: 'demo-start', sourceHandle: 'output', target: 'demo-loop-1', targetHandle: 'input' }, + { id: 'e-l1-finish', source: 'demo-loop-1', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-finish', targetHandle: 'input' }, + + // Loop 1 body: start → loop2 → 1a → 1b → 1c → continue + { id: 'e-l1s-l2', source: 'demo-loop-1', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-loop-2', targetHandle: 'input' }, + { id: 'e-l2ok-1a', source: 'demo-loop-2', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-1a', targetHandle: 'input' }, + { id: 'e-1a-1b', source: 'demo-1a', sourceHandle: 'output', target: 'demo-1b', targetHandle: 'input' }, + { id: 'e-1b-1c', source: 'demo-1b', sourceHandle: 'output', target: 'demo-1c', targetHandle: 'input' }, + { id: 'e-1c-l1c', source: 'demo-1c', sourceHandle: 'output', target: 'demo-loop-1', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + + // Loop 2 body: start → loop3 → 2a → 2b → 2c → continue + { id: 'e-l2s-l3', source: 'demo-loop-2', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-loop-3', targetHandle: 'input' }, + { id: 'e-l3ok-2a', source: 'demo-loop-3', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-2a', targetHandle: 'input' }, + { id: 'e-2a-2b', source: 'demo-2a', sourceHandle: 'output', target: 'demo-2b', targetHandle: 'input' }, + { id: 'e-2b-2c', source: 'demo-2b', sourceHandle: 'output', target: 'demo-2c', targetHandle: 'input' }, + { id: 'e-2c-l2c', source: 'demo-2c', sourceHandle: 'output', target: 'demo-loop-2', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + + // Loop 3 body: start → loop4 → 3a → 3b → 3c → continue + { id: 'e-l3s-l4', source: 'demo-loop-3', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-loop-4', targetHandle: 'input' }, + { id: 'e-l4ok-3a', source: 'demo-loop-4', sourceHandle: STORY_LOOP_SUCCESS_HANDLE_ID, target: 'demo-3a', targetHandle: 'input' }, + { id: 'e-3a-3b', source: 'demo-3a', sourceHandle: 'output', target: 'demo-3b', targetHandle: 'input' }, + { id: 'e-3b-3c', source: 'demo-3b', sourceHandle: 'output', target: 'demo-3c', targetHandle: 'input' }, + { id: 'e-3c-l3c', source: 'demo-3c', sourceHandle: 'output', target: 'demo-loop-3', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + + // Loop 4 body: start → 4a → 4b → 4c → continue + { id: 'e-l4s-4a', source: 'demo-loop-4', sourceHandle: STORY_LOOP_START_HANDLE_ID, target: 'demo-4a', targetHandle: 'input' }, + { id: 'e-4a-4b', source: 'demo-4a', sourceHandle: 'output', target: 'demo-4b', targetHandle: 'input' }, + { id: 'e-4b-4c', source: 'demo-4b', sourceHandle: 'output', target: 'demo-4c', targetHandle: 'input' }, + { id: 'e-4c-l4c', source: 'demo-4c', sourceHandle: 'output', target: 'demo-loop-4', targetHandle: STORY_LOOP_CONTINUE_HANDLE_ID }, + ], []); + + const { canvasProps, nodeTypeRegistry } = useCanvasStory({ + initialNodes, + initialEdges, + additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES_PILL, + }); + + const loopPreviewOptions = useMemo( + () => ({ + getManifestForNode: (node: Node) => + node.type ? nodeTypeRegistry.getManifest(node.type) : undefined, + }), + [nodeTypeRegistry] + ); + + const handleHandleAction = useCallback( + (event: CanvasHandleActionEvent) => { + const { handleId, nodeId, position, handleType } = event; + if (!handleId || !nodeId) return; + createAddNodePreview( + nodeId, + handleId, + reactFlow, + position as Position, + handleType === 'input' ? 'target' : 'source', + [], + loopPreviewOptions + ); + }, + [loopPreviewOptions, reactFlow] + ); + + useCanvasEvent('handle:action', handleHandleAction); + + const handlePaneClick = useCallback(() => { + removePreviewFromReactFlow(reactFlow); + }, [reactFlow]); + + return ( + + + + + + + ); +} + +export const Anatomy: Story = { + name: 'Anatomy', + render: () => , +}; + +export const Default: Story = { + render: () => , +}; + +export const NestedOuterOutputInsert: Story = { + render: () => , +}; + +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: () => , +}; + +export const ExecutionStatesV2: Story = { + name: 'Execution States V2 — Compound Picker', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => LOOP_EXECUTION_STATUS.get(nodeId), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; + +export const LoopNodeV2Demo: Story = { + name: 'Demo - Option B', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => V2_DEMO_LOOP_STATUS.get(nodeId), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: () => , +}; diff --git a/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx b/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx index 45ab4b908..3aefbc033 100644 --- a/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx +++ b/packages/apollo-react/src/canvas/utils/adornment-resolver.tsx @@ -1,5 +1,5 @@ import { CanvasIcon, ExecutionStatusIcon } from '@uipath/apollo-react/canvas'; -import { memo } from 'react'; +import { createContext, memo, useContext } from 'react'; import type { NodeAdornments, NodeStatusContext } from '../components'; import { CanvasTooltip } from '../components/CanvasTooltip'; import { getExecutionStatusColor } from '../components/ExecutionStatusIcon/ExecutionStatusIcon'; @@ -57,7 +57,7 @@ function ExecutionStatusIndicatorInternal({ status, count }: { status?: string; ); } -function SquareDashedIndicator() { +export function SquareDashedIndicator() { return ( @@ -143,3 +143,20 @@ export function resolveAdornments( // TODO: for now, always return default adornments return getDefaultAdornments(context, options); } + +// --------------------------------------------------------------------------- +// Optional per-story resolver override +// When AdornmentResolverProvider wraps a story, BaseNode uses that resolver +// instead of the default resolveAdornments. The original BaseNode story never +// wraps with this provider, so it is completely unaffected. +// --------------------------------------------------------------------------- + +type AdornmentResolverFn = (context: NodeStatusContext) => NodeAdornments; + +const AdornmentResolverContext = createContext(null); + +export const AdornmentResolverProvider = AdornmentResolverContext.Provider; + +export function useAdornmentResolver(): AdornmentResolverFn { + return useContext(AdornmentResolverContext) ?? resolveAdornments; +} diff --git a/packages/apollo-wind/rslib.config.ts b/packages/apollo-wind/rslib.config.ts index 135cb72c6..8e81eeed5 100644 --- a/packages/apollo-wind/rslib.config.ts +++ b/packages/apollo-wind/rslib.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ dts: { build: true, distPath: './dist', + abortOnError: false, }, bundle: false, },