/* eslint-disable @typescript-eslint/no-explicit-any */ import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Collapsible, CollapsibleContent, CollapsibleTrigger, ConfirmDialog, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectItemWithDescription, SelectTrigger, SelectValue, Switch, } from '@plunk/ui'; import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import type {PaginatedResponse} from '@plunk/types'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; import { AlertTriangle, ArrowLeft, ChevronDown, Clock, GitBranch, Info, LogOut, Mail, Plus, Power, PowerOff, Settings, Trash2, UserCog, Users, Webhook, } from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; import {WorkflowBuilder} from '../../components/WorkflowBuilder'; import {ReactFlowProvider} from '@xyflow/react'; import {WorkflowSchemas} from '@plunk/shared'; import dayjs from 'dayjs'; interface WorkflowWithDetails extends Workflow { steps: (WorkflowStep & { template?: {id: string; name: string} | null; outgoingTransitions: WorkflowTransition[]; incomingTransitions: WorkflowTransition[]; })[]; } interface PaginatedExecutions { executions: (WorkflowExecution & { contact: {id: string; email: string}; currentStep?: {id: string; name: string; type: string} | null; })[]; total: number; page: number; pageSize: number; totalPages: number; } // Step type styling (matching WorkflowBuilder) 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', }; export default function WorkflowEditorPage() { const router = useRouter(); const {id} = router.query; const [activeTab, setActiveTab] = useState<'builder' | 'executions'>('builder'); const [showSettingsDialog, setShowSettingsDialog] = useState(false); const [editingStep, setEditingStep] = useState(null); const [showCancelAllDialog, setShowCancelAllDialog] = useState(false); const [executionToCancel, setExecutionToCancel] = useState(null); const [isCancelling, setIsCancelling] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const {data: workflow, mutate} = useSWR(id ? `/workflows/${id}` : null, { revalidateOnFocus: false, }); const {data: executionsData} = useSWR( id && activeTab === 'executions' ? `/workflows/${id}/executions?page=1&pageSize=10` : null, {revalidateOnFocus: false}, ); // Always fetch a summary of active executions to show warnings (regardless of enabled status) const {data: activeExecutionsData} = useSWR( id ? `/workflows/${id}/executions?page=1&pageSize=1&status=RUNNING` : null, {revalidateOnFocus: false, refreshInterval: 10000}, ); const {data: waitingExecutionsData} = useSWR( id ? `/workflows/${id}/executions?page=1&pageSize=1&status=WAITING` : null, {revalidateOnFocus: false, refreshInterval: 10000}, ); // Check for active executions const activeExecutionsCount = (activeExecutionsData?.total || 0) + (waitingExecutionsData?.total || 0); // Handler for cancelling a single execution const handleCancelExecution = async (executionId: string) => { setIsCancelling(true); try { await network.fetch('DELETE', `/workflows/${id}/executions/${executionId}`); toast.success('Execution cancelled successfully'); void mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to cancel execution'); } finally { setIsCancelling(false); setExecutionToCancel(null); } }; // Handler for cancelling all executions const handleCancelAllExecutions = async () => { setIsCancelling(true); try { const result = await network.fetch<{cancelled: number}>('POST', `/workflows/${id}/executions/cancel-all`); toast.success(`Successfully cancelled ${result.cancelled} execution(s)`); void mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to cancel executions'); } finally { setIsCancelling(false); setShowCancelAllDialog(false); } }; // Validate workflow configuration const validateWorkflow = (workflow: WorkflowWithDetails): {valid: boolean; errors: string[]} => { const errors: string[] = []; // Check if there are any steps if (workflow.steps.length === 0) { errors.push('Workflow must have at least one step'); return {valid: false, errors}; } // Validate each step workflow.steps.forEach(step => { const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {}; switch (step.type) { case 'SEND_EMAIL': if (!step.templateId) { errors.push(`"${step.name}" step is missing an email template`); } break; case 'DELAY': if (!config.amount || !config.unit) { errors.push(`"${step.name}" step is missing delay configuration (amount or unit)`); } break; case 'CONDITION': // Extract field name from both legacy format (object) and new format (string) let fieldValue = ''; if (config.field) { if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) { fieldValue = String(config.field.field || ''); } else { fieldValue = String(config.field); } } if (!fieldValue || !config.operator) { errors.push(`"${step.name}" step is missing condition configuration (field or operator)`); } // Check if value is required for this operator const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || '')); if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) { errors.push(`"${step.name}" step is missing a value for the condition`); } break; case 'WAIT_FOR_EVENT': if (!config.eventName) { errors.push(`"${step.name}" step is missing event name`); } break; case 'WEBHOOK': if (!config.url) { errors.push(`"${step.name}" step is missing webhook URL`); } break; case 'UPDATE_CONTACT': if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) { errors.push(`"${step.name}" step is missing contact updates`); } break; } }); // Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT) const triggerSteps = workflow.steps.filter(s => s.type === 'TRIGGER'); workflow.steps.forEach(step => { if (step.type !== 'TRIGGER' && step.type !== 'EXIT') { const hasIncoming = step.incomingTransitions && step.incomingTransitions.length > 0; const hasOutgoing = step.outgoingTransitions && step.outgoingTransitions.length > 0; if (!hasIncoming && !hasOutgoing) { errors.push(`"${step.name}" step is not connected to the workflow`); } } }); // Check if there's a TRIGGER step if (triggerSteps.length === 0) { errors.push('Workflow must have a trigger step'); } // Check for CONDITION steps that don't have both yes and no branches workflow.steps.forEach(step => { if (step.type === 'CONDITION' && step.outgoingTransitions) { 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 || !hasNoBranch) { errors.push(`"${step.name}" condition step must have both YES and NO branches connected`); } } }); return {valid: errors.length === 0, errors}; }; const handleToggleEnabled = async () => { if (!workflow) return; // If trying to enable, validate first if (!workflow.enabled) { const validation = validateWorkflow(workflow); if (!validation.valid) { toast.error(
Cannot enable workflow
    {validation.errors.map((error, i) => (
  • {error}
  • ))}
, {duration: 8000}, ); return; } } try { await network.fetch('PATCH', `/workflows/${id}`, { enabled: !workflow.enabled, }); toast.success(`Workflow ${!workflow.enabled ? 'enabled' : 'disabled'} successfully`); void mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to toggle workflow'); } }; const handleUpdateSettings = async (data: {name: string; description?: string}) => { try { await network.fetch('PATCH', `/workflows/${id}`, data); toast.success('Workflow updated successfully'); void mutate(); setShowSettingsDialog(false); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to update workflow'); } }; const handleDelete = async () => { try { await network.fetch('DELETE', `/workflows/${id}`); toast.success('Workflow deleted successfully'); void router.push('/workflows'); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to delete workflow'); } }; // Listen for edit step events from the WorkflowBuilder useEffect(() => { const handleEditStepEvent = (event: Event) => { const customEvent = event as CustomEvent<{stepId?: string}>; const stepId = customEvent.detail?.stepId; if (stepId && workflow) { const step = workflow.steps.find(s => s.id === stepId); if (step) { setEditingStep(step); } } }; window.addEventListener('workflow-edit-step', handleEditStepEvent); return () => { window.removeEventListener('workflow-edit-step', handleEditStepEvent); }; }, [workflow]); if (!workflow) { return (

Loading workflow...

); } return (
{/* Header */}

{workflow.name}

{workflow.enabled ? ( <> Active ) : ( <> Disabled )}
{workflow.description && (

{workflow.description}

)}
{/* Active Executions Warning Banner */} {activeExecutionsCount > 0 && ( {workflow.enabled ? 'Workflow is active with running executions' : 'Workflow has active executions'}

This workflow has {activeExecutionsCount} active execution {activeExecutionsCount !== 1 ? 's' : ''}.{' '} {!workflow.enabled && 'Even though the workflow is disabled, existing executions will continue. '} To protect running workflows, you cannot:

  • Delete steps or transitions
  • Modify step configurations (email templates, conditions, etc.)
  • Change the workflow trigger

You can still rename steps and adjust their position. To make configuration changes, wait for executions to complete or cancel them from the Executions tab.

)} {/* Validation Warning Banner */} {!workflow.enabled && (() => { const validation = validateWorkflow(workflow); if (!validation.valid) { return ( Workflow has validation errors

Fix the following issues before enabling this workflow:

    {validation.errors.map((error, i) => (
  • {error}
  • ))}
); } return null; })()} {/* Tabs */}
{/* Content */} {activeTab === 'builder' ? ( Workflow Builder Click the + buttons to add and connect steps to your workflow. mutate()} /> ) : activeTab === 'executions' ? (
Workflow Executions View and manage all executions of this workflow
{activeExecutionsCount > 0 && ( )}
{!executionsData?.executions.length ? (

No executions yet

This workflow hasn't been executed yet. Enable it to start processing contacts.

) : (
{executionsData.executions.map(execution => ( ))}
Contact Status Current Step Started Actions
{execution.contact.email} {execution.status} {execution.currentStep?.name ?? '-'}
{dayjs(execution.startedAt).fromNow()}
{dayjs(execution.startedAt).format('DD MMMM YYYY, hh:mm')}
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && ( )}
)}
) : null}
{/* Dialogs */} {workflow && ( <> {editingStep && ( !open && setEditingStep(null)} onSuccess={() => mutate()} /> )} {/* Cancel Single Execution Confirmation */} !open && setExecutionToCancel(null)} onConfirm={() => { if (executionToCancel) { return handleCancelExecution(executionToCancel); } }} title="Cancel Execution" description={ executionToCancel && executionsData?.executions ? (

Are you sure you want to cancel the workflow execution for{' '} {executionsData.executions.find(e => e.id === executionToCancel)?.contact.email || 'this contact'} ?

The contact will not receive any remaining emails or actions from this workflow. This action cannot be undone.

) : ( 'Are you sure you want to cancel this execution?' ) } confirmText="Cancel Execution" cancelText="Keep Running" variant="destructive" isLoading={isCancelling} /> {/* Cancel All Executions Confirmation */}

Are you sure you want to cancel all {activeExecutionsCount} active execution {activeExecutionsCount !== 1 ? 's' : ''}?

All contacts currently in this workflow will be stopped and won't receive any remaining emails or actions. This action cannot be undone.

} confirmText={`Cancel ${activeExecutionsCount} Execution${activeExecutionsCount !== 1 ? 's' : ''}`} cancelText="Keep Running" variant="destructive" isLoading={isCancelling} /> {/* Delete Workflow Confirmation */} )}
); } // Settings Dialog Component interface SettingsDialogProps { workflow: Workflow; open: boolean; onOpenChange: (open: boolean) => void; onSave: (data: {name: string; description?: string; allowReentry?: boolean}) => Promise; } function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogProps) { const [name, setName] = useState(workflow.name); const [description, setDescription] = useState(workflow.description ?? ''); const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); try { await onSave({name, description: description || undefined, allowReentry}); } finally { setIsSubmitting(false); } }; return ( Workflow Settings
setName(e.target.value)} required />