/* eslint-disable @typescript-eslint/no-explicit-any */ import { Background, Controls, type Edge, Handle, MarkerType, MiniMap, type Node, Panel, Position, ReactFlow, useEdgesState, useNodesState, useReactFlow } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type {WorkflowStep} from '@plunk/db'; import { Clock, GitBranch, Hourglass, Lightbulb, Link, LogOut, Mail, Plus, Settings, Timer, Trash2, UserCog, Webhook } from 'lucide-react'; import {useCallback, useEffect, useMemo, useState} from 'react'; import dagre from 'dagre'; import {network} from '../lib/network'; import {toast} from 'sonner'; import {Button, ConfirmDialog, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@plunk/ui'; import {WorkflowSchemas} from '@plunk/shared'; interface WorkflowBuilderProps { workflowId: string; 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; }>; })[]; onUpdate: () => void; } 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', SEND_EMAIL: '#2563eb', DELAY: '#ea580c', WAIT_FOR_EVENT: '#ca8a04', CONDITION: '#9333ea', EXIT: '#dc2626', WEBHOOK: '#16a34a', UPDATE_CONTACT: '#4f46e5', }; const STEP_TYPE_BG = { TRIGGER: '#f3e8ff', SEND_EMAIL: '#dbeafe', DELAY: '#ffedd5', WAIT_FOR_EVENT: '#fef3c7', CONDITION: '#f3e8ff', EXIT: '#fee2e2', WEBHOOK: '#dcfce7', UPDATE_CONTACT: '#e0e7ff', }; // Dagre layout function function getLayoutedElements(nodes: Node[], edges: Edge[]) { const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); const nodeWidth = 280; const nodeHeight = 120; dagreGraph.setGraph({ rankdir: 'TB', nodesep: 100, ranksep: 150, 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}; } // Add Step Node - appears at the end of flow paths function AddStepNode({data}: {data: {label: string; onClick?: () => void}}) { return ( <>
{data.label &&
{data.label}
}
); } // Custom node component with action buttons function CustomNode({ data, }: { data: { label: string; type: string; stepId?: string; icon?: any; color?: string; bgColor?: string; onEdit?: () => void; onDelete?: () => void; template?: {name: string}; config?: any; }; }) { const Icon = data.icon; const color = data.color; const bgColor = data.bgColor; const [showActions, setShowActions] = useState(false); return ( <>
setShowActions(true)} onMouseLeave={() => setShowActions(false)} > {/* Action buttons - shown on hover */} {showActions && data.type !== 'TRIGGER' && (
)} {/* 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' && data.config && (
{/* Handle both legacy format {field, type} and new format (string) */} {typeof data.config.field === 'object' && data.config.field !== null && 'field' in data.config.field ? String(data.config.field.field) : String(data.config.field)}
{data.config.operator} "{String(data.config.value)}"
)} {data.type === 'WAIT_FOR_EVENT' && data.config?.eventName && (
{data.config.eventName}
)} {data.type === 'WEBHOOK' && data.config?.url && (
{data.config.method || 'POST'} {data.config.url}
)}
); } const nodeTypes = { custom: CustomNode, addStep: AddStepNode, }; // Step type options for adding new steps const STEP_TYPE_OPTIONS = [ {value: 'SEND_EMAIL', label: 'Send Email', icon: Mail, color: STEP_TYPE_COLORS.SEND_EMAIL}, {value: 'DELAY', label: 'Delay', icon: Clock, color: STEP_TYPE_COLORS.DELAY}, {value: 'WAIT_FOR_EVENT', label: 'Wait for Event', icon: Clock, color: STEP_TYPE_COLORS.WAIT_FOR_EVENT}, {value: 'CONDITION', label: 'Condition', icon: GitBranch, color: STEP_TYPE_COLORS.CONDITION}, {value: 'WEBHOOK', label: 'Webhook', icon: Webhook, color: STEP_TYPE_COLORS.WEBHOOK}, {value: 'UPDATE_CONTACT', label: 'Update Contact', icon: UserCog, color: STEP_TYPE_COLORS.UPDATE_CONTACT}, {value: 'EXIT', label: 'Exit', icon: LogOut, color: STEP_TYPE_COLORS.EXIT}, ]; export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderProps) { const reactFlowInstance = useReactFlow(); const [addStepContext, setAddStepContext] = useState<{ fromStepId: string | null; branch?: 'yes' | 'no'; } | null>(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [stepToDelete, setStepToDelete] = useState(null); // Define handlers before they are used in useMemo const handleEditStep = useCallback((stepId: string) => { // This will be handled by the parent component const event = new CustomEvent('workflow-edit-step', {detail: {stepId}}); window.dispatchEvent(event); }, []); const handleDeleteStepClick = useCallback((stepId: string) => { setStepToDelete(stepId); setShowDeleteDialog(true); }, []); // 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: step.position ? (step.position as {x: number; y: number}) : {x: 0, y: 0}, data: { label: step.name, type: step.type, icon: Icon, color, bgColor, template: step.template, config: step.config, onEdit: () => handleEditStep(step.id), onDelete: () => handleDeleteStepClick(step.id), }, }; }); // Add "Add Step" nodes at the end of each flow path steps.forEach(step => { if (step.type === 'EXIT') return; // Exit steps can't have next steps if (step.type === 'CONDITION') { // Check for yes and no branches const hasYesBranch = step.outgoingTransitions?.some(t => { const condition = t.condition; return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes'; }); const hasNoBranch = step.outgoingTransitions?.some(t => { const condition = t.condition; return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no'; }); if (!hasYesBranch) { nodes.push({ id: `${step.id}-add-yes`, type: 'addStep', position: {x: 0, y: 0}, data: { label: 'Yes', onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}), }, }); } if (!hasNoBranch) { nodes.push({ id: `${step.id}-add-no`, type: 'addStep', position: {x: 0, y: 0}, data: { label: 'No', onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}), }, }); } } else { // For non-condition steps, add + node if no outgoing transitions if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) { nodes.push({ id: `${step.id}-add`, type: 'addStep', position: {x: 0, y: 0}, data: { label: '', onClick: () => setAddStepContext({fromStepId: step.id}), }, }); } } }); return nodes; }, [steps, handleEditStep, handleDeleteStepClick]); // Convert transitions to React Flow edges const rawEdges: Edge[] = useMemo(() => { const edges: Edge[] = []; steps.forEach(step => { if (step.outgoingTransitions && step.outgoingTransitions.length > 0) { step.outgoingTransitions.forEach(transition => { const condition = transition.condition; const isConditional = condition && typeof condition === 'object' && 'branch' in condition; const branch = condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined; edges.push({ id: transition.id, source: step.id, target: transition.toStepId, type: 'smoothstep', animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT', 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.5, }, markerEnd: { type: MarkerType.ArrowClosed, color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8', width: 22, height: 22, }, }); }); } // Add edges from steps to their "Add Step" nodes if (step.type === 'EXIT') return; 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}-add-yes-edge`, source: step.id, target: `${step.id}-add-yes`, 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.5, strokeDasharray: '5,5'}, markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22}, }); } if (!hasNoBranch) { edges.push({ id: `${step.id}-add-no-edge`, source: step.id, target: `${step.id}-add-no`, 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.5, strokeDasharray: '5,5'}, markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22}, }); } } else { if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) { edges.push({ id: `${step.id}-add-edge`, source: step.id, target: `${step.id}-add`, type: 'smoothstep', animated: false, style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'}, markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22}, }); } } }); 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]); // Handle creating a new step from the + node const handleCreateStep = useCallback( async (stepType: string) => { if (!addStepContext?.fromStepId) return; try { // Validate that this branch doesn't already have a transition const fromStep = steps.find(s => s.id === addStepContext.fromStepId); if (!fromStep) { toast.error('Parent step not found'); return; } // For CONDITION steps, check if the branch already exists if (fromStep.type === 'CONDITION' && addStepContext.branch) { const existingBranchTransition = fromStep.outgoingTransitions?.find(t => { const condition = t.condition; return ( condition && typeof condition === 'object' && 'branch' in condition && condition.branch === addStepContext.branch ); }); if (existingBranchTransition) { toast.error(`The ${addStepContext.branch} branch already has a connection`); return; } } // Create the new step (autoConnect: false because we manually create the transition with branch info) const newStep = await network.fetch( 'POST', `/workflows/${workflowId}/steps`, { type: stepType as WorkflowStep['type'], name: `New ${stepType.toLowerCase().replace('_', ' ')}`, position: {x: 0, y: 0}, // Will be auto-positioned by dagre layout config: {}, autoConnect: false, // We manually create transitions to preserve branch information }, ); const newStepId = newStep.id; // Create the transition with proper condition const condition = addStepContext.branch ? {branch: addStepContext.branch} : null; const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0; await network.fetch( 'POST', `/workflows/${workflowId}/transitions`, { fromStepId: addStepContext.fromStepId, toStepId: newStepId, condition, priority, }, ); toast.success('Step added successfully'); setAddStepContext(null); onUpdate(); // Trigger edit dialog for the new step after a short delay setTimeout(() => { const event = new CustomEvent('workflow-edit-step', {detail: {stepId: newStepId}}); window.dispatchEvent(event); }, 100); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to add step'); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [addStepContext, workflowId, onUpdate], ); // Get all steps that will be affected by deleting a step (the step itself + all downstream steps) const getAffectedSteps = useCallback( (stepId: string): typeof steps => { const affected = new Set(); const queue = [stepId]; // BFS to find all downstream steps while (queue.length > 0) { const currentId = queue.shift()!; if (affected.has(currentId)) continue; affected.add(currentId); const currentStep = steps.find(s => s.id === currentId); if (currentStep?.outgoingTransitions) { for (const transition of currentStep.outgoingTransitions) { if (!affected.has(transition.toStepId)) { queue.push(transition.toStepId); } } } } return steps.filter(s => affected.has(s.id)); }, [steps], ); const handleDeleteStep = async () => { if (!stepToDelete) return; try { await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`); const affectedSteps = getAffectedSteps(stepToDelete); if (affectedSteps.length > 1) { toast.success(`Deleted ${affectedSteps.length} steps`); } else { toast.success('Step deleted'); } onUpdate(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to delete step'); } finally { setStepToDelete(null); } }; // Auto-layout on demand const handleAutoLayout = useCallback(() => { const {nodes: newNodes, edges: newEdges} = getLayoutedElements(nodes, edges); setNodes(newNodes); setEdges(newEdges); // Fit view after layout setTimeout(() => { reactFlowInstance?.fitView({padding: 0.3}); }, 10); }, [nodes, edges, setNodes, setEdges, reactFlowInstance]); if (steps.length === 0) { return (

No workflow steps yet

Add your first step to get started

); } return ( <>
{ const step = steps.find(s => s.id === node.id); return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280'; }} className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg" maskColor="rgba(0, 0, 0, 0.05)" />
{steps.length} step{steps.length !== 1 ? 's' : ''} ยท {rawEdges.length} connection{rawEdges.length !== 1 ? 's' : ''}
{rawEdges.length === 0 && steps.length > 1 && (
Click the + buttons to add and connect steps!
)}
{/* Step type picker dialog */} !open && setAddStepContext(null)}> Add Step
{STEP_TYPE_OPTIONS.map(option => { const Icon = option.icon; return ( ); })}
{stepToDelete && (() => { const affectedSteps = getAffectedSteps(stepToDelete); const stepToDeleteData = steps.find(s => s.id === stepToDelete); const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete); return ( 0 ? (

Deleting "{stepToDeleteData?.name}" will also delete {downstreamSteps.length} downstream{' '} {downstreamSteps.length === 1 ? 'step' : 'steps'}:

    {downstreamSteps.map(step => (
  • {step.name} ({step.type})
  • ))}

This action cannot be undone.

) : ( `Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.` ) } confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'} variant="destructive" /> ); })()} ); }