Files
cameleer-server/ui/src/components/ProcessDiagram/ProcessDiagram.tsx

354 lines
11 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from 'react';
import type { ProcessDiagramProps } from './types';
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import { useDiagramData } from './useDiagramData';
import { useZoomPan } from './useZoomPan';
import { useToolbarHover, NodeToolbar } from './NodeToolbar';
import { DiagramNode } from './DiagramNode';
import { DiagramEdge } from './DiagramEdge';
import { CompoundNode } from './CompoundNode';
import { ErrorSection } from './ErrorSection';
import { ZoomControls } from './ZoomControls';
import { isCompoundType } from './node-colors';
import styles from './ProcessDiagram.module.css';
const PADDING = 40;
/** Types that support drill-down — double-click navigates to the target route */
const DRILLDOWN_TYPES = new Set(['DIRECT', 'SEDA']);
/** Extract the target endpoint name from a node's label */
function extractTargetEndpoint(node: DiagramNodeType): string | null {
// Labels like "to: direct:orderProcessing" or "direct:orderProcessing"
const label = node.label ?? '';
const match = label.match(/(?:to:\s*)?(?:direct|seda):(\S+)/i);
return match ? match[1] : null;
}
/** Convert camelCase to kebab-case: "callGetProduct" → "call-get-product" */
function camelToKebab(s: string): string {
return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
/**
* Resolve a direct/seda endpoint name to a routeId.
* Tries: exact match, kebab-case conversion, then gives up.
*/
function resolveRouteId(endpoint: string, knownRouteIds: Set<string>): string | null {
if (knownRouteIds.has(endpoint)) return endpoint;
const kebab = camelToKebab(endpoint);
if (knownRouteIds.has(kebab)) return kebab;
return null;
}
export function ProcessDiagram({
application,
routeId,
direction = 'LR',
selectedNodeId,
onNodeSelect,
onNodeAction,
nodeConfigs,
knownRouteIds,
className,
}: ProcessDiagramProps) {
// Route stack for drill-down navigation
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
// Reset stack when the external routeId prop changes
useEffect(() => {
setRouteStack([routeId]);
}, [routeId]);
const currentRouteId = routeStack[routeStack.length - 1];
const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
application, currentRouteId, direction,
);
const zoom = useZoomPan();
const toolbar = useToolbarHover();
const contentWidth = totalWidth + PADDING * 2;
const contentHeight = totalHeight + PADDING * 2;
// Reset to 100% at top-left when route changes
useEffect(() => {
if (totalWidth > 0 && totalHeight > 0) {
zoom.resetView();
}
}, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNodeClick = useCallback(
(nodeId: string) => { onNodeSelect?.(nodeId); },
[onNodeSelect],
);
const handleNodeDoubleClick = useCallback(
(nodeId: string) => {
const node = findNodeById(sections, nodeId);
if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return;
const endpoint = extractTargetEndpoint(node);
if (!endpoint) return;
const resolved = knownRouteIds
? resolveRouteId(endpoint, knownRouteIds)
: endpoint;
if (resolved) {
onNodeSelect?.('');
setRouteStack(prev => [...prev, resolved]);
}
},
[sections, onNodeSelect, knownRouteIds],
);
const handleBreadcrumbClick = useCallback(
(index: number) => {
onNodeSelect?.('');
setRouteStack(prev => prev.slice(0, index + 1));
},
[onNodeSelect],
);
const handleNodeAction = useCallback(
(nodeId: string, action: import('./types').NodeAction) => {
if (action === 'inspect') onNodeSelect?.(nodeId);
onNodeAction?.(nodeId, action);
},
[onNodeSelect, onNodeAction],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
if (routeStack.length > 1) {
// Go back one level
setRouteStack(prev => prev.slice(0, -1));
} else {
onNodeSelect?.('');
}
return;
}
zoom.onKeyDown(e, contentWidth, contentHeight);
},
[onNodeSelect, zoom, contentWidth, contentHeight, routeStack.length],
);
if (isLoading) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.loading}>Loading diagram...</div>
</div>
);
}
if (error) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.error}>Failed to load diagram</div>
</div>
);
}
if (sections.length === 0) {
return (
<div className={`${styles.container} ${className ?? ''}`}>
<div className={styles.loading}>No diagram data available</div>
</div>
);
}
const mainSection = sections[0];
const handlerSections = sections.slice(1);
return (
<div
ref={zoom.containerRef}
className={`${styles.container} ${className ?? ''}`}
>
{/* Breadcrumb bar — only shown when drilled down */}
{routeStack.length > 1 && (
<div className={styles.breadcrumbs}>
{routeStack.map((route, i) => (
<span key={i} className={styles.breadcrumbItem}>
{i > 0 && <span className={styles.breadcrumbSep}>/</span>}
{i < routeStack.length - 1 ? (
<button
className={styles.breadcrumbLink}
onClick={() => handleBreadcrumbClick(i)}
>
{route}
</button>
) : (
<span className={styles.breadcrumbCurrent}>{route}</span>
)}
</span>
))}
</div>
)}
<svg
className={styles.svg}
onWheel={zoom.onWheel}
onPointerDown={zoom.onPointerDown}
onPointerMove={zoom.onPointerMove}
onPointerUp={zoom.onPointerUp}
onKeyDown={handleKeyDown}
tabIndex={0}
onClick={() => onNodeSelect?.('')}
>
<defs>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="7"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" />
</marker>
</defs>
<g style={{ transform: zoom.transform, transformOrigin: '0 0' }}>
{/* Main section top-level edges (not inside compounds) */}
<g className="edges">
{mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
</g>
{/* Main section nodes */}
<g className="nodes">
{mainSection.nodes.map(node => {
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
return (
<CompoundNode
key={node.id}
node={node}
edges={mainSection.edges}
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
onNodeLeave={toolbar.onNodeLeave}
/>
);
}
return (
<DiagramNode
key={node.id}
node={node}
isHovered={toolbar.hoveredNodeId === node.id}
isSelected={selectedNodeId === node.id}
config={node.id ? nodeConfigs?.get(node.id) : undefined}
onClick={() => node.id && handleNodeClick(node.id)}
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
onMouseLeave={toolbar.onNodeLeave}
/>
);
})}
</g>
{/* Toolbar rendered as HTML overlay below */}
{/* Handler sections (completion, then error) */}
{handlerSections.map((section, i) => (
<ErrorSection
key={`handler-${i}`}
section={section}
totalWidth={totalWidth}
selectedNodeId={selectedNodeId}
hoveredNodeId={toolbar.hoveredNodeId}
nodeConfigs={nodeConfigs}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
onNodeLeave={toolbar.onNodeLeave}
/>
))}
</g>
</svg>
{/* Node toolbar — HTML overlay, fixed size regardless of zoom */}
{toolbar.hoveredNodeId && onNodeAction && (() => {
const hNode = findNodeById(sections, toolbar.hoveredNodeId!);
if (!hNode) return null;
const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2;
const nodeTop = hNode.y ?? 0;
const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX;
const screenY = nodeTop * zoom.state.scale + zoom.state.translateY;
return (
<NodeToolbar
nodeId={toolbar.hoveredNodeId!}
screenX={screenX}
screenY={screenY}
onAction={handleNodeAction}
onMouseEnter={toolbar.onToolbarEnter}
onMouseLeave={toolbar.onToolbarLeave}
/>
);
})()}
<ZoomControls
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onFitToView={() => zoom.fitToView(contentWidth, contentHeight)}
scale={zoom.state.scale}
/>
</div>
);
}
function findNodeById(
sections: import('./types').DiagramSection[],
nodeId: string,
): DiagramNodeType | undefined {
for (const section of sections) {
for (const node of section.nodes) {
if (node.id === nodeId) return node;
if (node.children) {
const found = findInChildren(node.children, nodeId);
if (found) return found;
}
}
}
return undefined;
}
function findInChildren(
nodes: DiagramNodeType[],
nodeId: string,
): DiagramNodeType | undefined {
for (const n of nodes) {
if (n.id === nodeId) return n;
if (n.children) {
const found = findInChildren(n.children, nodeId);
if (found) return found;
}
}
return undefined;
}
function topLevelEdge(
edge: import('../../api/queries/diagrams').DiagramEdge,
nodes: DiagramNodeType[],
): boolean {
const compoundChildIds = new Set<string>();
for (const n of nodes) {
if (n.children && n.children.length > 0) {
collectDescendantIds(n.children, compoundChildIds);
}
}
return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId);
}
function collectDescendantIds(nodes: DiagramNodeType[], set: Set<string>) {
for (const n of nodes) {
if (n.id) set.add(n.id);
if (n.children) collectDescendantIds(n.children, set);
}
}