diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx
index dd0d1d4..95317ac 100644
--- a/apps/web/src/pages/workflows/[id].tsx
+++ b/apps/web/src/pages/workflows/[id].tsx
@@ -1,11 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
ConfirmDialog,
Dialog,
DialogContent,
@@ -19,12 +25,29 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
- Switch,
+ Switch
} from '@plunk/ui';
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
-import {ArrowLeft, Play, Power, PowerOff, Settings, Users} from 'lucide-react';
+import {
+ ArrowLeft,
+ ChevronDown,
+ Clock,
+ GitBranch,
+ Info,
+ LogOut,
+ Mail,
+ Play,
+ 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';
@@ -53,6 +76,40 @@ interface PaginatedExecutions {
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;
@@ -187,7 +244,6 @@ export default function WorkflowEditorPage() {
// Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT)
const triggerSteps = workflow.steps.filter(s => s.type === 'TRIGGER');
- const exitSteps = workflow.steps.filter(s => s.type === 'EXIT');
workflow.steps.forEach(step => {
if (step.type !== 'TRIGGER' && step.type !== 'EXIT') {
@@ -414,38 +470,39 @@ export default function WorkflowEditorPage() {
)}
{/* 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}
- ))}
-
+ {!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;
- })()}
+ );
+ }
+ return null;
+ })()}
{/* Tabs */}
@@ -862,7 +919,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
// WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState('');
- const [eventTimeout, setEventTimeout] = useState('86400');
+ const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1');
+ const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>('days');
// WEBHOOK fields
const [webhookUrl, setWebhookUrl] = useState('');
@@ -880,6 +938,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
const {data: templatesData} = useSWR<{templates: Template[]}>('/templates?pageSize=100');
const {data: workflow} = useSWR
(workflowId ? `/workflows/${workflowId}` : null);
+ // Fetch available event names when dialog opens
+ const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, {
+ revalidateOnFocus: false,
+ });
+
// Fetch available fields when dialog opens and type is CONDITION
useEffect(() => {
const fetchAvailableFields = async () => {
@@ -928,7 +991,15 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
}
config = {templateId};
} else if (type === 'DELAY') {
- config = {amount: parseInt(delayAmount), unit: delayUnit};
+ const amount = parseInt(delayAmount);
+ // Validate max 365 days
+ const maxValues = {minutes: 525600, hours: 8760, days: 365};
+ if (amount > maxValues[delayUnit]) {
+ toast.error(`Delay cannot exceed 365 days (${maxValues[delayUnit]} ${delayUnit})`);
+ setIsSubmitting(false);
+ return;
+ }
+ config = {amount, unit: delayUnit};
} else if (type === 'CONDITION') {
// Parse the value based on type
let parsedValue: string | number | boolean = conditionValue;
@@ -987,9 +1058,32 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
setIsSubmitting(false);
return;
}
+ // Convert amount + unit to seconds
+ const amount = parseInt(eventTimeoutAmount);
+ // Validate max 365 days
+ const maxValues = {minutes: 525600, hours: 8760, days: 365};
+ if (amount > maxValues[eventTimeoutUnit]) {
+ toast.error(`Timeout cannot exceed 365 days (${maxValues[eventTimeoutUnit]} ${eventTimeoutUnit})`);
+ setIsSubmitting(false);
+ return;
+ }
+ let timeoutSeconds = 0;
+ if (amount > 0) {
+ switch (eventTimeoutUnit) {
+ case 'minutes':
+ timeoutSeconds = amount * 60;
+ break;
+ case 'hours':
+ timeoutSeconds = amount * 60 * 60;
+ break;
+ case 'days':
+ timeoutSeconds = amount * 60 * 60 * 24;
+ break;
+ }
+ }
config = {
eventName,
- timeout: parseInt(eventTimeout),
+ timeout: timeoutSeconds,
};
}
@@ -1015,7 +1109,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
setConditionValue('');
setAvailableFields([]);
setEventName('');
- setEventTimeout('86400');
+ setEventTimeoutAmount('1');
+ setEventTimeoutUnit('days');
setWebhookUrl('');
setWebhookMethod('POST');
setWebhookHeaders('');
@@ -1092,30 +1187,44 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* DELAY Configuration */}
{type === 'DELAY' && (
-
-
-
- setDelayAmount(e.target.value)}
- required
- min="1"
- />
-
-
-
-
+
+
+
+
+ setDelayAmount(e.target.value)}
+ required
+ min="1"
+ max={
+ delayUnit === 'minutes'
+ ? 525600
+ : delayUnit === 'hours'
+ ? 8760
+ : delayUnit === 'days'
+ ? 365
+ : undefined
+ }
+ />
+
+
+
+
+
)}
@@ -1223,30 +1332,71 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
-
setEventName(e.target.value)}
- required
- placeholder="e.g., email.clicked, user.upgraded"
- />
-
The event name to wait for
+ {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
+
+ ) : (
+
setEventName(e.target.value)}
+ required
+ placeholder="e.g., email.clicked, user.upgraded"
+ />
+ )}
+
+ {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0
+ ? 'Select from previously tracked events'
+ : 'The event name to wait for'}
+
-
-
setEventTimeout(e.target.value)}
- placeholder="86400"
- min="0"
- />
-
- How long to wait before continuing (0 = wait forever). Default: 86400 (24 hours)
-
+
+
+ setEventTimeoutAmount(e.target.value)}
+ placeholder="1"
+ min="0"
+ max={
+ eventTimeoutUnit === 'minutes'
+ ? 525600
+ : eventTimeoutUnit === 'hours'
+ ? 8760
+ : eventTimeoutUnit === 'days'
+ ? 365
+ : undefined
+ }
+ className="flex-1"
+ />
+
+
)}
@@ -1318,14 +1468,21 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{type === 'EXIT' && (
-
setExitReason(e.target.value)}
- placeholder="e.g., unsubscribed, completed, not_eligible"
- />
-
Optional reason for exiting (for tracking/analytics)
+
)}
@@ -1358,6 +1515,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
const [name, setName] = useState(step.name);
const [isSubmitting, setIsSubmitting] = useState(false);
+ // Get icon and colors for this step type
+ 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';
+
// SEND_EMAIL fields
const [templateId, setTemplateId] = useState(step.template?.id || '');
@@ -1387,12 +1549,34 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
// WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState(String(config?.eventName || ''));
- const [eventTimeout, setEventTimeout] = useState(String(config?.timeout || '86400'));
+ const [eventTimeoutAmount, setEventTimeoutAmount] = useState
(() => {
+ const timeoutSeconds = Number(config?.timeout) || 86400;
+ // Convert seconds to most appropriate unit
+ if (timeoutSeconds === 0) return '0';
+ if (timeoutSeconds % (60 * 60 * 24) === 0) return String(timeoutSeconds / (60 * 60 * 24));
+ if (timeoutSeconds % (60 * 60) === 0) return String(timeoutSeconds / (60 * 60));
+ if (timeoutSeconds % 60 === 0) return String(timeoutSeconds / 60);
+ // Default to hours if not evenly divisible
+ return String(Math.round(timeoutSeconds / (60 * 60)));
+ });
+ const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>(() => {
+ const timeoutSeconds = Number(config?.timeout) || 86400;
+ if (timeoutSeconds === 0) return 'days';
+ if (timeoutSeconds % (60 * 60 * 24) === 0) return 'days';
+ if (timeoutSeconds % (60 * 60) === 0) return 'hours';
+ return 'minutes';
+ });
// WEBHOOK fields
const [webhookUrl, setWebhookUrl] = useState(String(config?.url || ''));
const [webhookMethod, setWebhookMethod] = useState(String(config?.method || 'POST'));
- const [webhookHeaders, setWebhookHeaders] = useState(config?.headers ? JSON.stringify(config.headers, null, 2) : '');
+ const [webhookHeaders, setWebhookHeaders] = useState<{key: string; value: string}[]>(() => {
+ if (config?.headers && typeof config.headers === 'object') {
+ return Object.entries(config.headers).map(([key, value]) => ({key, value: String(value)}));
+ }
+ return [];
+ });
+ const [showWebhookInfo, setShowWebhookInfo] = useState(false);
// UPDATE_CONTACT fields
const [contactUpdates, setContactUpdates] = useState(config?.updates ? JSON.stringify(config.updates, null, 2) : '');
@@ -1403,6 +1587,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
const {data: templatesData} = useSWR<{templates: Template[]}>('/templates?pageSize=100');
const {data: workflow} = useSWR(workflowId ? `/workflows/${workflowId}` : null);
+ // Fetch available event names when dialog opens
+ const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, {
+ revalidateOnFocus: false,
+ });
+
// Fetch available contact fields when dialog opens and type is CONDITION
useEffect(() => {
const fetchAvailableFields = async () => {
@@ -1445,7 +1634,15 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
}
newConfig = {templateId};
} else if (step.type === 'DELAY') {
- newConfig = {amount: parseInt(delayAmount), unit: delayUnit};
+ const amount = parseInt(delayAmount);
+ // Validate max 365 days
+ const maxValues = {minutes: 525600, hours: 8760, days: 365};
+ if (amount > maxValues[delayUnit]) {
+ toast.error(`Delay cannot exceed 365 days (${maxValues[delayUnit]} ${delayUnit})`);
+ setIsSubmitting(false);
+ return;
+ }
+ newConfig = {amount, unit: delayUnit};
} else if (step.type === 'CONDITION') {
// Parse the value based on type
let parsedValue: string | number | boolean = conditionValue;
@@ -1467,16 +1664,13 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
return;
}
- let headers = {};
- if (webhookHeaders.trim()) {
- try {
- headers = JSON.parse(webhookHeaders);
- } catch {
- toast.error('Invalid JSON in webhook headers');
- setIsSubmitting(false);
- return;
+ // Convert headers array to object, filtering out empty entries
+ const headers: Record = {};
+ webhookHeaders.forEach(header => {
+ if (header.key.trim() && header.value.trim()) {
+ headers[header.key.trim()] = header.value.trim();
}
- }
+ });
newConfig = {
url: webhookUrl,
@@ -1504,9 +1698,32 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
setIsSubmitting(false);
return;
}
+ // Convert amount + unit to seconds
+ const amount = parseInt(eventTimeoutAmount);
+ // Validate max 365 days
+ const maxValues = {minutes: 525600, hours: 8760, days: 365};
+ if (amount > maxValues[eventTimeoutUnit]) {
+ toast.error(`Timeout cannot exceed 365 days (${maxValues[eventTimeoutUnit]} ${eventTimeoutUnit})`);
+ setIsSubmitting(false);
+ return;
+ }
+ let timeoutSeconds = 0;
+ if (amount > 0) {
+ switch (eventTimeoutUnit) {
+ case 'minutes':
+ timeoutSeconds = amount * 60;
+ break;
+ case 'hours':
+ timeoutSeconds = amount * 60 * 60;
+ break;
+ case 'days':
+ timeoutSeconds = amount * 60 * 60 * 24;
+ break;
+ }
+ }
newConfig = {
eventName,
- timeout: parseInt(eventTimeout),
+ timeout: timeoutSeconds,
};
}
@@ -1536,6 +1753,20 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
Edit Step
+
+
+
+
+
+ {step.type.replace(/_/g, ' ')}
+
+