feat: add multi-flow support to RouteFlow via flows prop
All checks were successful
Build & Publish / publish (push) Successful in 50s
All checks were successful
Build & Publish / publish (push) Successful in 50s
Allow rendering multiple named flow segments (e.g., main route + onException handler) within a single RouteFlow component. Each segment gets a labeled section with variant-based color theming. Fully backward compatible — existing nodes prop continues to work unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,3 +248,40 @@
|
|||||||
.badgeSuccess { background: var(--success); }
|
.badgeSuccess { background: var(--success); }
|
||||||
.badgeWarning { background: var(--amber); }
|
.badgeWarning { background: var(--amber); }
|
||||||
.badgeError { background: var(--error); }
|
.badgeError { background: var(--error); }
|
||||||
|
|
||||||
|
/* Node wrapper (replaces inline style) */
|
||||||
|
.nodeWrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-flow sections */
|
||||||
|
.flowSection {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowSectionSeparated {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 2px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flowLabelDefault { color: var(--text-muted); }
|
||||||
|
.flowLabelError { color: var(--error); }
|
||||||
|
.flowLabelWarning { color: var(--warning); }
|
||||||
|
.flowLabelInfo { color: var(--running); }
|
||||||
|
|||||||
@@ -81,3 +81,80 @@ describe('RouteFlow', () => {
|
|||||||
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
|
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const multiFlows = [
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'timer:tick', type: 'from' as const, durationMs: 0, status: 'ok' as const },
|
||||||
|
{ name: 'Processor1', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException',
|
||||||
|
variant: 'error' as const,
|
||||||
|
nodes: [
|
||||||
|
{ name: 'LogHandler', type: 'process' as const, durationMs: 3, status: 'ok' as const },
|
||||||
|
{ name: 'dead-letter:errors', type: 'to' as const, durationMs: 8, status: 'fail' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('RouteFlow (multi-flow)', () => {
|
||||||
|
it('renders all segment labels', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('onException')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all nodes across segments', () => {
|
||||||
|
render(<RouteFlow flows={multiFlows} />)
|
||||||
|
expect(screen.getByText('timer:tick')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Processor1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('LogHandler')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('dead-letter:errors')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses global flat indexing for onNodeClick', async () => {
|
||||||
|
const onNodeClick = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<RouteFlow flows={multiFlows} onNodeClick={onNodeClick} />)
|
||||||
|
// Click the first node of the second flow (global index = 2)
|
||||||
|
await user.click(screen.getByText('LogHandler'))
|
||||||
|
expect(onNodeClick).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'LogHandler' }),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectedIndex highlights correct node across flows', () => {
|
||||||
|
const { container } = render(<RouteFlow flows={multiFlows} selectedIndex={3} />)
|
||||||
|
// Index 3 = dead-letter:errors (2nd node of 2nd flow)
|
||||||
|
const selectedNodes = container.querySelectorAll('[class*="nodeSelected"]')
|
||||||
|
expect(selectedNodes.length).toBe(1)
|
||||||
|
expect(selectedNodes[0]).toHaveTextContent('dead-letter:errors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('actions work in multi-flow mode', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RouteFlow
|
||||||
|
flows={multiFlows}
|
||||||
|
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||||
|
expect(triggers.length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flows takes precedence over nodes', () => {
|
||||||
|
render(
|
||||||
|
<RouteFlow
|
||||||
|
nodes={nodes}
|
||||||
|
flows={multiFlows}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Should render flow content, not nodes content
|
||||||
|
expect(screen.getByText('Main Route')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('jms:orders')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ export interface NodeAction {
|
|||||||
divider?: boolean
|
divider?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteFlowProps {
|
export interface FlowSegment {
|
||||||
|
label: string
|
||||||
nodes: RouteNode[]
|
nodes: RouteNode[]
|
||||||
|
variant?: 'default' | 'error' | 'warning' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteFlowProps {
|
||||||
|
nodes?: RouteNode[]
|
||||||
|
flows?: FlowSegment[]
|
||||||
onNodeClick?: (node: RouteNode, index: number) => void
|
onNodeClick?: (node: RouteNode, index: number) => void
|
||||||
selectedIndex?: number
|
selectedIndex?: number
|
||||||
actions?: NodeAction[]
|
actions?: NodeAction[]
|
||||||
@@ -102,12 +109,110 @@ function renderActionTrigger(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
const FLOW_LABEL_CLASSES: Record<string, string> = {
|
||||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
'default': styles.flowLabelDefault,
|
||||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
'error': styles.flowLabelError,
|
||||||
|
'warning': styles.flowLabelWarning,
|
||||||
|
'info': styles.flowLabelInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeChain(
|
||||||
|
nodes: RouteNode[],
|
||||||
|
globalIndexOffset: number,
|
||||||
|
onNodeClick?: RouteFlowProps['onNodeClick'],
|
||||||
|
selectedIndex?: number,
|
||||||
|
actions?: NodeAction[],
|
||||||
|
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||||
|
) {
|
||||||
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
|
return nodes.map((node, i) => {
|
||||||
|
const globalIndex = globalIndexOffset + i
|
||||||
|
const isSelected = selectedIndex === globalIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
|
{i > 0 && (
|
||||||
|
<div className={styles.connector}>
|
||||||
|
<div className={styles.connectorLine} />
|
||||||
|
<div className={styles.connectorArrow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`${styles.node} ${nodeStatusClass(node)} ${isSelected ? styles.nodeSelected : ''} ${isClickable ? styles.nodeClickable : ''}`}
|
||||||
|
onClick={() => onNodeClick?.(node, globalIndex)}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault()
|
||||||
|
onNodeClick?.(node, globalIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(node.isBottleneck || node.badges?.length) ? (
|
||||||
|
<span className={styles.badgeRow}>
|
||||||
|
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||||
|
{node.badges?.map((badge, bi) => (
|
||||||
|
<span
|
||||||
|
key={bi}
|
||||||
|
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||||
|
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||||
|
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
|
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stats}>
|
||||||
|
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||||
|
{formatDuration(node.durationMs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderActionTrigger(node, globalIndex, isSelected, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||||
|
// Multi-flow mode
|
||||||
|
if (flows && flows.length > 0) {
|
||||||
|
let globalOffset = 0
|
||||||
|
return (
|
||||||
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
|
{flows.map((flow, fi) => {
|
||||||
|
const sectionOffset = globalOffset
|
||||||
|
globalOffset += flow.nodes.length
|
||||||
|
const variant = flow.variant ?? 'default'
|
||||||
|
const labelClass = FLOW_LABEL_CLASSES[variant] ?? styles.flowLabelDefault
|
||||||
|
return (
|
||||||
|
<div key={fi} className={`${styles.flowSection} ${fi > 0 ? styles.flowSectionSeparated : ''}`}>
|
||||||
|
<div className={`${styles.flowLabel} ${labelClass}`}>{flow.label}</div>
|
||||||
|
{renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode (single nodes array with automatic error-handler separation)
|
||||||
|
const allNodes = nodes ?? []
|
||||||
|
const mainNodes = allNodes.filter((n) => n.type !== 'error-handler')
|
||||||
|
const errorHandlers = allNodes.filter((n) => n.type === 'error-handler')
|
||||||
|
|
||||||
// Map from mainNodes index back to original nodes index
|
// Map from mainNodes index back to original nodes index
|
||||||
const mainNodeOriginalIndices = nodes.reduce<number[]>((acc, n, idx) => {
|
const mainNodeOriginalIndices = allNodes.reduce<number[]>((acc, n, idx) => {
|
||||||
if (n.type !== 'error-handler') acc.push(idx)
|
if (n.type !== 'error-handler') acc.push(idx)
|
||||||
return acc
|
return acc
|
||||||
}, [])
|
}, [])
|
||||||
@@ -120,7 +225,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
|
|||||||
const isClickable = !!onNodeClick
|
const isClickable = !!onNodeClick
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div key={i} className={styles.nodeWrapper}>
|
||||||
{i > 0 && (
|
{i > 0 && (
|
||||||
<div className={styles.connector}>
|
<div className={styles.connector}>
|
||||||
<div className={styles.connectorLine} />
|
<div className={styles.connectorLine} />
|
||||||
@@ -176,7 +281,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
|
|||||||
<div className={styles.errorSection}>
|
<div className={styles.errorSection}>
|
||||||
<div className={styles.errorLabel}>Error Handler</div>
|
<div className={styles.errorLabel}>Error Handler</div>
|
||||||
{errorHandlers.map((node, i) => {
|
{errorHandlers.map((node, i) => {
|
||||||
const errOriginalIndex = nodes.indexOf(node)
|
const errOriginalIndex = allNodes.indexOf(node)
|
||||||
return (
|
return (
|
||||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export { Popover } from './Popover/Popover'
|
|||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||||
export type { RouteNode, NodeAction, NodeBadge } from './RouteFlow/RouteFlow'
|
export type { RouteNode, NodeAction, NodeBadge, FlowSegment } from './RouteFlow/RouteFlow'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
export { SplitPane } from './SplitPane/SplitPane'
|
export { SplitPane } from './SplitPane/SplitPane'
|
||||||
|
|||||||
@@ -815,6 +815,37 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 17c. RouteFlow (Multi-Flow) */}
|
||||||
|
<DemoCard
|
||||||
|
id="routeflow-multi"
|
||||||
|
title="RouteFlow (Multi-Flow)"
|
||||||
|
description="Multiple flow segments with labels, showing a main route alongside an exception handler."
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<RouteFlow
|
||||||
|
flows={[
|
||||||
|
{
|
||||||
|
label: 'Main Route',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'jms:orders', type: 'from', durationMs: 4, status: 'ok' },
|
||||||
|
{ name: 'OrderValidator', type: 'process', durationMs: 8, status: 'ok' },
|
||||||
|
{ name: 'http:payment-api/charge', type: 'to', durationMs: 187, status: 'slow' },
|
||||||
|
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'onException(IOException)',
|
||||||
|
variant: 'error',
|
||||||
|
nodes: [
|
||||||
|
{ name: 'log:error-logger', type: 'process', durationMs: 2, status: 'ok' },
|
||||||
|
{ name: 'dead-letter:failed-orders', type: 'to', durationMs: 14, status: 'fail' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 18. ShortcutsBar */}
|
{/* 18. ShortcutsBar */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="shortcutsbar"
|
id="shortcutsbar"
|
||||||
|
|||||||
Reference in New Issue
Block a user