/* eslint-disable @typescript-eslint/no-explicit-any */ import { Alert, AlertDescription, AlertTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ConfirmDialog, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, EmptyState, Input, Label, Command, CommandGroup, CommandItem, CommandList, IconSpinner, Switch, } from '@plunk/ui'; import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; import { AlertTriangle, ArrowLeft, Info, Power, PowerOff, Settings, Trash2, Users, } 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 {NextSeo} from 'next-seo'; import {WorkflowBuilder} from '../../components/WorkflowBuilder'; import {EditStepDialog} from '../../components/workflow-steps'; 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; } export default function WorkflowEditorPage() { const router = useRouter(); const {id} = router.query; const [activeTab, setActiveTab] = useState<'builder' | 'executions'>('builder'); type WorkflowDialog = | {type: 'none'} | {type: 'settings'} | {type: 'cancelAll'; cancelling: boolean} | {type: 'cancelOne'; executionId: string; cancelling: boolean} | {type: 'editStep'; step: WorkflowStep} | {type: 'delete'}; const [dialog, setDialog] = useState({type: 'none'}); 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) => { setDialog({type: 'cancelOne', executionId, cancelling: true}); try { await network.fetch('DELETE', `/workflows/${id}/executions/${executionId}`); toast.success('Execution cancelled successfully'); setDialog({type: 'none'}); void mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to cancel execution'); setDialog({type: 'cancelOne', executionId, cancelling: false}); } }; // Handler for cancelling all executions const handleCancelAllExecutions = async () => { setDialog(d => (d.type === 'cancelAll' ? {...d, cancelling: true} : d)); try { const result = await network.fetch<{cancelled: number}>('POST', `/workflows/${id}/executions/cancel-all`); toast.success(`Successfully cancelled ${result.cancelled} execution(s)`); setDialog({type: 'none'}); void mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to cancel executions'); setDialog(d => (d.type === 'cancelAll' ? {...d, cancelling: false} : d)); } }; // 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': if (config.mode === 'multi') { // Multi-branch validation if (!config.field) { errors.push(`"${step.name}" step is missing condition field`); } if (!Array.isArray(config.branches) || config.branches.length === 0) { errors.push(`"${step.name}" step needs at least one branch`); } } else { // 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': { const hasUpdates = config.updates && typeof config.updates === 'object' && Object.keys(config.updates).length > 0; const hasSubscriptionAction = typeof config.subscriptionAction === 'string' && config.subscriptionAction !== 'none' && config.subscriptionAction !== ''; if (!hasUpdates && !hasSubscriptionAction) { errors.push(`"${step.name}" step is missing contact updates or a subscription action`); } 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 all required branches connected workflow.steps.forEach(step => { if (step.type === 'CONDITION' && step.outgoingTransitions) { const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {}; // Determine expected branches based on mode let expectedBranches: string[]; if ((config as any).mode === 'multi' && Array.isArray((config as any).branches)) { expectedBranches = [...(config as any).branches.map((b: any) => b.id), 'default']; } else { expectedBranches = ['yes', 'no']; } const missingBranches = expectedBranches.filter(branchId => { return !step.outgoingTransitions.some(t => { const condition = t.condition; return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId; }); }); if (missingBranches.length > 0) { if ((config as any).mode === 'multi') { const branchNames = missingBranches.map(id => { if (id === 'default') return 'Default'; const branch = (config as any).branches?.find((b: any) => b.id === id); return branch?.name || id; }); errors.push(`"${step.name}" condition step is missing connections for: ${branchNames.join(', ')}`); } else { 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; allowReentry?: boolean; triggerConfig?: {eventName: string}; }) => { try { await network.fetch('PATCH', `/workflows/${id}`, data); toast.success('Workflow updated successfully'); void mutate(); setDialog({type: 'none'}); } 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) { setDialog({type: 'editStep', step}); } } }; const handleOpenSettingsEvent = () => { setDialog({type: 'settings'}); }; window.addEventListener('workflow-edit-step', handleEditStepEvent); window.addEventListener('workflow-open-settings', handleOpenSettingsEvent); return () => { window.removeEventListener('workflow-edit-step', handleEditStepEvent); window.removeEventListener('workflow-open-settings', handleOpenSettingsEvent); }; }, [workflow]); if (!workflow) { return (
); } 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 / Ready-to-enable 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}
  • ))}
); } if (workflow.steps.length > 0) { return ( Workflow is disabled Contacts won't be processed until this workflow is enabled. ); } 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 ? ( ) : (
{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 && ( <> !open && setDialog({type: 'none'})} onSave={handleUpdateSettings} /> {dialog.type === 'editStep' && ( !open && setDialog({type: 'none'})} onSuccess={() => mutate()} /> )} {/* Cancel Single Execution Confirmation */} !open && setDialog({type: 'none'})} onConfirm={() => { if (dialog.type === 'cancelOne') { return handleCancelExecution(dialog.executionId); } }} title="Cancel Execution" description={ dialog.type === 'cancelOne' && executionsData?.executions ? (

Are you sure you want to cancel the workflow execution for{' '} {executionsData.executions.find(e => e.id === dialog.executionId)?.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" status={dialog.type === 'cancelOne' && dialog.cancelling ? 'loading' : 'idle'} /> {/* Cancel All Executions Confirmation */} !open && setDialog({type: 'none'})} onConfirm={handleCancelAllExecutions} title="Cancel All Active Executions" description={

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" status={dialog.type === 'cancelAll' && dialog.cancelling ? 'loading' : 'idle'} /> {/* Delete Workflow Confirmation */} !open && setDialog({type: 'none'})} onConfirm={handleDelete} title="Delete Workflow" description="Are you sure you want to delete this workflow? This action cannot be undone." confirmText="Delete Workflow" variant="destructive" /> )} ); } // Settings Dialog Component interface SettingsDialogProps { workflow: Workflow; open: boolean; onOpenChange: (open: boolean) => void; onSave: (data: { name: string; description?: string; allowReentry?: boolean; triggerConfig?: {eventName: string}; }) => Promise; } function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogProps) { const triggerConfig = workflow.triggerConfig as {eventName?: string} | null; const [name, setName] = useState(workflow.name); const [description, setDescription] = useState(workflow.description ?? ''); const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false); const [eventName, setEventName] = useState(triggerConfig?.eventName ?? ''); const [eventPopoverOpen, setEventPopoverOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); // Sync state when workflow changes or dialog opens useEffect(() => { if (open) { setName(workflow.name); setDescription(workflow.description ?? ''); setAllowReentry(workflow.allowReentry ?? false); const config = workflow.triggerConfig as {eventName?: string} | null; setEventName(config?.eventName ?? ''); } }, [open, workflow]); // Fetch available event names const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, { revalidateOnFocus: false, }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); try { await onSave({ name, description: description || undefined, allowReentry, triggerConfig: eventName.trim() ? {eventName: eventName.trim()} : undefined, }); } finally { setIsSubmitting(false); } }; return ( Workflow Settings
setName(e.target.value)} required />