/* eslint-disable @typescript-eslint/no-explicit-any */ import { Background, Controls, type Edge, Handle, MarkerType, type Node, Panel, Position, ReactFlow, useEdgesState, useNodesState, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type {WorkflowStep} from '@plunk/db'; import {Clock, GitBranch, LogOut, Mail, UserCog, Webhook} from 'lucide-react'; import {useEffect, useMemo} from 'react'; import dagre from 'dagre'; interface WorkflowVisualizerProps { steps: (WorkflowStep & { template?: {id: string; name: string} | null; outgoingTransitions: Array<{ id: string; toStepId: string; condition: unknown; priority: number; }>; incomingTransitions: Array<{ id: string; fromStepId: string; condition: unknown; priority: number; }>; })[]; } const STEP_TYPE_ICONS = { TRIGGER: GitBranch, SEND_EMAIL: Mail, DELAY: Clock, WAIT_FOR_EVENT: Clock, CONDITION: GitBranch, EXIT: LogOut, WEBHOOK: Webhook, UPDATE_CONTACT: UserCog, }; const STEP_TYPE_COLORS = { TRIGGER: '#9333ea', // purple-600 SEND_EMAIL: '#2563eb', // blue-600 DELAY: '#ea580c', // orange-600 WAIT_FOR_EVENT: '#ca8a04', // yellow-600 CONDITION: '#9333ea', // purple-600 EXIT: '#dc2626', // red-600 WEBHOOK: '#16a34a', // green-600 UPDATE_CONTACT: '#4f46e5', // indigo-600 }; const STEP_TYPE_BG = { TRIGGER: '#f3e8ff', // purple-50 SEND_EMAIL: '#dbeafe', // blue-50 DELAY: '#ffedd5', // orange-50 WAIT_FOR_EVENT: '#fef3c7', // yellow-50 CONDITION: '#f3e8ff', // purple-50 EXIT: '#fee2e2', // red-50 WEBHOOK: '#dcfce7', // green-50 UPDATE_CONTACT: '#e0e7ff', // indigo-50 }; // Dagre layout function function getLayoutedElements(nodes: Node[], edges: Edge[]) { const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); const nodeWidth = 250; const nodeHeight = 100; dagreGraph.setGraph({ rankdir: 'TB', // Top to Bottom nodesep: 80, // Horizontal spacing ranksep: 120, // Vertical spacing marginx: 50, marginy: 50, }); nodes.forEach(node => { dagreGraph.setNode(node.id, {width: nodeWidth, height: nodeHeight}); }); edges.forEach(edge => { dagreGraph.setEdge(edge.source, edge.target); }); dagre.layout(dagreGraph); const layoutedNodes = nodes.map(node => { const nodeWithPosition = dagreGraph.node(node.id); return { ...node, position: { x: nodeWithPosition.x - nodeWidth / 2, y: nodeWithPosition.y - nodeHeight / 2, }, }; }); return {nodes: layoutedNodes, edges}; } // Custom node component function CustomNode({ data, }: { data: { label: string; type: string; icon?: any; color?: string; bgColor?: string; template?: {name: string}; config?: any; }; }) { const Icon = data.icon; const color = data.color; const bgColor = data.bgColor; return ( <> {/* Target Handle (top) - where edges come IN */}
{/* Header */}

{data.label}

{data.type}
{/* Details */} {data.template && (
๐Ÿ“ง {data.template.name}
)} {data.type === 'DELAY' && data.config?.amount && (
โฑ๏ธ Wait {data.config.amount} {data.config.unit}
)} {data.type === 'CONDITION' && (
๐Ÿ”€ If/Else Branch
)} {data.type === 'WAIT_FOR_EVENT' && data.config?.eventName && (
โณ {data.config.eventName}
)} {data.type === 'WEBHOOK' && data.config?.url && (
๐Ÿ”— {data.config.method || 'POST'}
)}
{/* Source Handle (bottom) - where edges go OUT */} ); } const nodeTypes = { custom: CustomNode, }; export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) { // Convert workflow steps to React Flow nodes const rawNodes: Node[] = useMemo(() => { if (steps.length === 0) return []; const nodes: Node[] = steps.map(step => { const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch; const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280'; const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6'; return { id: step.id, type: 'custom', position: {x: 0, y: 0}, // Will be set by layout data: { label: step.name, type: step.type, icon: Icon, color, bgColor, template: step.template, config: step.config, }, }; }); // Add END nodes for CONDITION steps with missing branches steps.forEach(step => { if (step.type === 'CONDITION') { const hasYesBranch = step.outgoingTransitions?.some( t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes', ); const hasNoBranch = step.outgoingTransitions?.some( t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no', ); if (!hasYesBranch) { nodes.push({ id: `${step.id}-yes-end`, type: 'custom', position: {x: 0, y: 0}, data: { label: 'End Workflow', type: 'END', icon: LogOut, color: '#9ca3af', bgColor: '#f3f4f6', template: null, config: null, }, }); } if (!hasNoBranch) { nodes.push({ id: `${step.id}-no-end`, type: 'custom', position: {x: 0, y: 0}, data: { label: 'End Workflow', type: 'END', icon: LogOut, color: '#9ca3af', bgColor: '#f3f4f6', template: null, config: null, }, }); } } }); return nodes; }, [steps]); // Convert transitions to React Flow edges const rawEdges: Edge[] = useMemo(() => { const edges: Edge[] = []; steps.forEach(step => { // Add edges for existing transitions if (step.outgoingTransitions && step.outgoingTransitions.length > 0) { step.outgoingTransitions.forEach(transition => { const isConditional = transition.condition && typeof transition.condition === 'object' && 'branch' in transition.condition; const branch = transition.condition && typeof transition.condition === 'object' && 'branch' in transition.condition ? transition.condition.branch : undefined; edges.push({ id: transition.id, source: step.id, target: transition.toStepId, type: 'smoothstep', animated: false, label: isConditional ? (branch === 'yes' ? 'โœ“ Yes' : 'โœ— No') : undefined, labelStyle: { fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b', fontWeight: 600, fontSize: 12, }, labelBgStyle: { fill: '#fff', fillOpacity: 0.95, }, labelBgPadding: [8, 4] as [number, number], labelBgBorderRadius: 4, style: { stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8', strokeWidth: 2, }, markerEnd: { type: MarkerType.ArrowClosed, color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8', width: 20, height: 20, }, }); }); } // Add edges to END nodes for CONDITION steps with missing branches if (step.type === 'CONDITION') { const hasYesBranch = step.outgoingTransitions?.some( t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes', ); const hasNoBranch = step.outgoingTransitions?.some( t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no', ); if (!hasYesBranch) { edges.push({ id: `${step.id}-yes-end-edge`, source: step.id, target: `${step.id}-yes-end`, type: 'smoothstep', animated: false, label: 'โœ“ Yes', labelStyle: { fill: '#16a34a', fontWeight: 600, fontSize: 12, }, labelBgStyle: { fill: '#fff', fillOpacity: 0.95, }, labelBgPadding: [8, 4] as [number, number], labelBgBorderRadius: 4, style: { stroke: '#16a34a', strokeWidth: 2, strokeDasharray: '5,5', }, markerEnd: { type: MarkerType.ArrowClosed, color: '#16a34a', width: 20, height: 20, }, }); } if (!hasNoBranch) { edges.push({ id: `${step.id}-no-end-edge`, source: step.id, target: `${step.id}-no-end`, type: 'smoothstep', animated: false, label: 'โœ— No', labelStyle: { fill: '#dc2626', fontWeight: 600, fontSize: 12, }, labelBgStyle: { fill: '#fff', fillOpacity: 0.95, }, labelBgPadding: [8, 4] as [number, number], labelBgBorderRadius: 4, style: { stroke: '#dc2626', strokeWidth: 2, strokeDasharray: '5,5', }, markerEnd: { type: MarkerType.ArrowClosed, color: '#dc2626', width: 20, height: 20, }, }); } } }); return edges; }, [steps]); // Apply dagre layout const {nodes: layoutedNodes, edges: layoutedEdges} = useMemo(() => { if (rawNodes.length === 0) return {nodes: [], edges: []}; return getLayoutedElements(rawNodes, rawEdges); }, [rawNodes, rawEdges]); const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges); // Update nodes/edges when layout changes useEffect(() => { setNodes(layoutedNodes); }, [layoutedNodes, setNodes]); useEffect(() => { setEdges(layoutedEdges); }, [layoutedEdges, setEdges]); if (steps.length === 0) { return (

No workflow steps yet

Add steps to your workflow to see the visualization

); } return (
{steps.length} step{steps.length !== 1 ? 's' : ''} ยท {rawEdges.length} transition{rawEdges.length !== 1 ? 's' : ''}
{rawEdges.length === 0 && steps.length > 1 && (
โš ๏ธ No transitions found. Connect your steps to see the flow.
)}
); }