diff --git a/src/design-system/composites/RouteFlow/RouteFlow.module.css b/src/design-system/composites/RouteFlow/RouteFlow.module.css index dc14ff8..ddeb48c 100644 --- a/src/design-system/composites/RouteFlow/RouteFlow.module.css +++ b/src/design-system/composites/RouteFlow/RouteFlow.module.css @@ -248,3 +248,40 @@ .badgeSuccess { background: var(--success); } .badgeWarning { background: var(--amber); } .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); } diff --git a/src/design-system/composites/RouteFlow/RouteFlow.test.tsx b/src/design-system/composites/RouteFlow/RouteFlow.test.tsx index bd03bf6..3c69142 100644 --- a/src/design-system/composites/RouteFlow/RouteFlow.test.tsx +++ b/src/design-system/composites/RouteFlow/RouteFlow.test.tsx @@ -81,3 +81,80 @@ describe('RouteFlow', () => { 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() + expect(screen.getByText('Main Route')).toBeInTheDocument() + expect(screen.getByText('onException')).toBeInTheDocument() + }) + + it('renders all nodes across segments', () => { + render() + 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() + // 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() + // 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( + {} }]} + />, + ) + const triggers = container.querySelectorAll('[aria-label*="Actions for"]') + expect(triggers.length).toBe(4) + }) + + it('flows takes precedence over nodes', () => { + render( + , + ) + // Should render flow content, not nodes content + expect(screen.getByText('Main Route')).toBeInTheDocument() + expect(screen.queryByText('jms:orders')).not.toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/RouteFlow/RouteFlow.tsx b/src/design-system/composites/RouteFlow/RouteFlow.tsx index faf411e..cc39031 100644 --- a/src/design-system/composites/RouteFlow/RouteFlow.tsx +++ b/src/design-system/composites/RouteFlow/RouteFlow.tsx @@ -25,8 +25,15 @@ export interface NodeAction { divider?: boolean } -interface RouteFlowProps { +export interface FlowSegment { + label: string nodes: RouteNode[] + variant?: 'default' | 'error' | 'warning' | 'info' +} + +interface RouteFlowProps { + nodes?: RouteNode[] + flows?: FlowSegment[] onNodeClick?: (node: RouteNode, index: number) => void selectedIndex?: number actions?: NodeAction[] @@ -102,12 +109,110 @@ function renderActionTrigger( ) } -export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) { - const mainNodes = nodes.filter((n) => n.type !== 'error-handler') - const errorHandlers = nodes.filter((n) => n.type === 'error-handler') +const FLOW_LABEL_CLASSES: Record = { + 'default': styles.flowLabelDefault, + '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 ( +
+ {i > 0 && ( +
+
+
+
+ )} +
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) ? ( + + {node.isBottleneck && BOTTLENECK} + {node.badges?.map((badge, bi) => ( + { e.stopPropagation(); badge.onClick!() } : undefined} + style={badge.onClick ? { cursor: 'pointer' } : undefined} + > + {badge.label} + + ))} + + ) : null} +
+ {TYPE_ICONS[node.type] ?? '\u25A2'} +
+
+
{node.type}
+
{node.name}
+
+
+
+ {formatDuration(node.durationMs)} +
+
+ {renderActionTrigger(node, globalIndex, isSelected, actions, getActions)} +
+
+ ) + }) +} + +export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) { + // Multi-flow mode + if (flows && flows.length > 0) { + let globalOffset = 0 + return ( +
+ {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 ( +
0 ? styles.flowSectionSeparated : ''}`}> +
{flow.label}
+ {renderNodeChain(flow.nodes, sectionOffset, onNodeClick, selectedIndex, actions, getActions)} +
+ ) + })} +
+ ) + } + + // 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 - const mainNodeOriginalIndices = nodes.reduce((acc, n, idx) => { + const mainNodeOriginalIndices = allNodes.reduce((acc, n, idx) => { if (n.type !== 'error-handler') acc.push(idx) return acc }, []) @@ -120,7 +225,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio const isClickable = !!onNodeClick return ( -
+
{i > 0 && (
@@ -176,7 +281,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActio
Error Handler
{errorHandlers.map((node, i) => { - const errOriginalIndex = nodes.indexOf(node) + const errOriginalIndex = allNodes.indexOf(node) return (
diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 947dd89..9b2018c 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -34,7 +34,7 @@ export { Popover } from './Popover/Popover' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline' 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 { SegmentedTabs } from './SegmentedTabs/SegmentedTabs' export { SplitPane } from './SplitPane/SplitPane' diff --git a/src/pages/Inventory/sections/CompositesSection.tsx b/src/pages/Inventory/sections/CompositesSection.tsx index 828a7f3..ac5abfa 100644 --- a/src/pages/Inventory/sections/CompositesSection.tsx +++ b/src/pages/Inventory/sections/CompositesSection.tsx @@ -815,6 +815,37 @@ export function CompositesSection() {
+ {/* 17c. RouteFlow (Multi-Flow) */} + +
+ +
+
+ {/* 18. ShortcutsBar */}