Improvement to workflow creation

This commit is contained in:
Dries Augustyns
2025-12-03 17:36:05 +01:00
parent 9fadd01a6e
commit c1f823e2f1
2 changed files with 590 additions and 177 deletions
+564 -172
View File
@@ -1,11 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
Alert,
AlertDescription,
AlertTitle,
Button, Button,
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,12 +25,29 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Switch, Switch
} from '@plunk/ui'; } from '@plunk/ui';
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network'; 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 Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
@@ -53,6 +76,40 @@ interface PaginatedExecutions {
totalPages: 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() { export default function WorkflowEditorPage() {
const router = useRouter(); const router = useRouter();
const {id} = router.query; 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) // 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 triggerSteps = workflow.steps.filter(s => s.type === 'TRIGGER');
const exitSteps = workflow.steps.filter(s => s.type === 'EXIT');
workflow.steps.forEach(step => { workflow.steps.forEach(step => {
if (step.type !== 'TRIGGER' && step.type !== 'EXIT') { if (step.type !== 'TRIGGER' && step.type !== 'EXIT') {
@@ -414,38 +470,39 @@ export default function WorkflowEditorPage() {
)} )}
{/* Validation Warning Banner */} {/* Validation Warning Banner */}
{!workflow.enabled && (() => { {!workflow.enabled &&
const validation = validateWorkflow(workflow); (() => {
if (!validation.valid) { const validation = validateWorkflow(workflow);
return ( if (!validation.valid) {
<div className="bg-amber-50 border-l-4 border-amber-400 p-4 rounded-r-lg"> return (
<div className="flex items-start"> <div className="bg-amber-50 border-l-4 border-amber-400 p-4 rounded-r-lg">
<div className="flex-shrink-0"> <div className="flex items-start">
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor"> <div className="flex-shrink-0">
<path <svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
fillRule="evenodd" <path
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" fillRule="evenodd"
clipRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
/> clipRule="evenodd"
</svg> />
</div> </svg>
<div className="ml-3 flex-1"> </div>
<h3 className="text-sm font-medium text-amber-800">Workflow has validation errors</h3> <div className="ml-3 flex-1">
<div className="mt-2 text-sm text-amber-700"> <h3 className="text-sm font-medium text-amber-800">Workflow has validation errors</h3>
<p className="mb-2">Fix the following issues before enabling this workflow:</p> <div className="mt-2 text-sm text-amber-700">
<ul className="list-disc list-inside space-y-1"> <p className="mb-2">Fix the following issues before enabling this workflow:</p>
{validation.errors.map((error, i) => ( <ul className="list-disc list-inside space-y-1">
<li key={i}>{error}</li> {validation.errors.map((error, i) => (
))} <li key={i}>{error}</li>
</ul> ))}
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> );
); }
} return null;
return null; })()}
})()}
{/* Tabs */} {/* Tabs */}
<div className="border-b border-neutral-200"> <div className="border-b border-neutral-200">
@@ -862,7 +919,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
// WAIT_FOR_EVENT fields // WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState(''); const [eventName, setEventName] = useState('');
const [eventTimeout, setEventTimeout] = useState('86400'); const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1');
const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>('days');
// WEBHOOK fields // WEBHOOK fields
const [webhookUrl, setWebhookUrl] = useState(''); 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: templatesData} = useSWR<{templates: Template[]}>('/templates?pageSize=100');
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null); const {data: workflow} = useSWR<WorkflowWithDetails>(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 // Fetch available fields when dialog opens and type is CONDITION
useEffect(() => { useEffect(() => {
const fetchAvailableFields = async () => { const fetchAvailableFields = async () => {
@@ -928,7 +991,15 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
} }
config = {templateId}; config = {templateId};
} else if (type === 'DELAY') { } 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') { } else if (type === 'CONDITION') {
// Parse the value based on type // Parse the value based on type
let parsedValue: string | number | boolean = conditionValue; let parsedValue: string | number | boolean = conditionValue;
@@ -987,9 +1058,32 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
setIsSubmitting(false); setIsSubmitting(false);
return; 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 = { config = {
eventName, eventName,
timeout: parseInt(eventTimeout), timeout: timeoutSeconds,
}; };
} }
@@ -1015,7 +1109,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
setConditionValue(''); setConditionValue('');
setAvailableFields([]); setAvailableFields([]);
setEventName(''); setEventName('');
setEventTimeout('86400'); setEventTimeoutAmount('1');
setEventTimeoutUnit('days');
setWebhookUrl(''); setWebhookUrl('');
setWebhookMethod('POST'); setWebhookMethod('POST');
setWebhookHeaders(''); setWebhookHeaders('');
@@ -1092,30 +1187,44 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* DELAY Configuration */} {/* DELAY Configuration */}
{type === 'DELAY' && ( {type === 'DELAY' && (
<div className="grid grid-cols-2 gap-4"> <div className="space-y-4">
<div> <div className="grid grid-cols-2 gap-4">
<Label htmlFor="delayAmount">Amount *</Label> <div>
<Input <Label htmlFor="delayAmount">Amount *</Label>
id="delayAmount" <Input
type="number" id="delayAmount"
value={delayAmount} type="number"
onChange={e => setDelayAmount(e.target.value)} value={delayAmount}
required onChange={e => setDelayAmount(e.target.value)}
min="1" required
/> min="1"
</div> max={
<div> delayUnit === 'minutes'
<Label htmlFor="delayUnit">Unit *</Label> ? 525600
<Select value={delayUnit} onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}> : delayUnit === 'hours'
<SelectTrigger id="delayUnit"> ? 8760
<SelectValue /> : delayUnit === 'days'
</SelectTrigger> ? 365
<SelectContent> : undefined
<SelectItem value="minutes">Minutes</SelectItem> }
<SelectItem value="hours">Hours</SelectItem> />
<SelectItem value="days">Days</SelectItem> </div>
</SelectContent> <div>
</Select> <Label htmlFor="delayUnit">Unit *</Label>
<Select
value={delayUnit}
onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}
>
<SelectTrigger id="delayUnit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
)} )}
@@ -1223,30 +1332,71 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="eventName">Event Name *</Label> <Label htmlFor="eventName">Event Name *</Label>
<Input {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
id="eventName" <Select value={eventName} onValueChange={setEventName} required>
type="text" <SelectTrigger id="eventName">
value={eventName} <SelectValue placeholder="Select an event..." />
onChange={e => setEventName(e.target.value)} </SelectTrigger>
required <SelectContent>
placeholder="e.g., email.clicked, user.upgraded" {eventNamesData.eventNames.map(name => (
/> <SelectItem key={name} value={name}>
<p className="text-xs text-neutral-500 mt-1">The event name to wait for</p> {name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="eventName"
type="text"
value={eventName}
onChange={e => setEventName(e.target.value)}
required
placeholder="e.g., email.clicked, user.upgraded"
/>
)}
<p className="text-xs text-neutral-500 mt-1">
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0
? 'Select from previously tracked events'
: 'The event name to wait for'}
</p>
</div> </div>
<div> <div>
<Label htmlFor="eventTimeout">Timeout (seconds)</Label> <Label htmlFor="eventTimeoutAmount">Timeout</Label>
<Input <div className="flex gap-2">
id="eventTimeout" <Input
type="number" id="eventTimeoutAmount"
value={eventTimeout} type="number"
onChange={e => setEventTimeout(e.target.value)} value={eventTimeoutAmount}
placeholder="86400" onChange={e => setEventTimeoutAmount(e.target.value)}
min="0" placeholder="1"
/> min="0"
<p className="text-xs text-neutral-500 mt-1"> max={
How long to wait before continuing (0 = wait forever). Default: 86400 (24 hours) eventTimeoutUnit === 'minutes'
</p> ? 525600
: eventTimeoutUnit === 'hours'
? 8760
: eventTimeoutUnit === 'days'
? 365
: undefined
}
className="flex-1"
/>
<Select
value={eventTimeoutUnit}
onValueChange={value => setEventTimeoutUnit(value as 'minutes' | 'hours' | 'days')}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
)} )}
@@ -1318,14 +1468,21 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{type === 'EXIT' && ( {type === 'EXIT' && (
<div> <div>
<Label htmlFor="exitReason">Exit Reason</Label> <Label htmlFor="exitReason">Exit Reason</Label>
<Input <Select value={exitReason} onValueChange={setExitReason}>
id="exitReason" <SelectTrigger id="exitReason">
type="text" <SelectValue placeholder="Select exit reason..." />
value={exitReason} </SelectTrigger>
onChange={e => setExitReason(e.target.value)} <SelectContent>
placeholder="e.g., unsubscribed, completed, not_eligible" <SelectItem value="completed">Completed - Contact completed the workflow successfully</SelectItem>
/> <SelectItem value="unsubscribed">Unsubscribed - Contact unsubscribed</SelectItem>
<p className="text-xs text-neutral-500 mt-1">Optional reason for exiting (for tracking/analytics)</p> <SelectItem value="not_eligible">Not Eligible - Contact doesn&apos;t meet criteria</SelectItem>
<SelectItem value="opted_out">Opted Out - Contact opted out of this workflow</SelectItem>
<SelectItem value="goal_achieved">Goal Achieved - Workflow goal was met early</SelectItem>
<SelectItem value="duplicate">Duplicate - Contact already in workflow</SelectItem>
<SelectItem value="error">Error - Technical issue occurred</SelectItem>
<SelectItem value="other">Other - Custom reason</SelectItem>
</SelectContent>
</Select>
</div> </div>
)} )}
@@ -1358,6 +1515,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
const [name, setName] = useState(step.name); const [name, setName] = useState(step.name);
const [isSubmitting, setIsSubmitting] = useState(false); 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 // SEND_EMAIL fields
const [templateId, setTemplateId] = useState(step.template?.id || ''); const [templateId, setTemplateId] = useState(step.template?.id || '');
@@ -1387,12 +1549,34 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
// WAIT_FOR_EVENT fields // WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState(String(config?.eventName || '')); const [eventName, setEventName] = useState(String(config?.eventName || ''));
const [eventTimeout, setEventTimeout] = useState(String(config?.timeout || '86400')); const [eventTimeoutAmount, setEventTimeoutAmount] = useState<string>(() => {
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 // WEBHOOK fields
const [webhookUrl, setWebhookUrl] = useState(String(config?.url || '')); const [webhookUrl, setWebhookUrl] = useState(String(config?.url || ''));
const [webhookMethod, setWebhookMethod] = useState(String(config?.method || 'POST')); 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 // UPDATE_CONTACT fields
const [contactUpdates, setContactUpdates] = useState(config?.updates ? JSON.stringify(config.updates, null, 2) : ''); 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: templatesData} = useSWR<{templates: Template[]}>('/templates?pageSize=100');
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null); const {data: workflow} = useSWR<WorkflowWithDetails>(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 // Fetch available contact fields when dialog opens and type is CONDITION
useEffect(() => { useEffect(() => {
const fetchAvailableFields = async () => { const fetchAvailableFields = async () => {
@@ -1445,7 +1634,15 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
} }
newConfig = {templateId}; newConfig = {templateId};
} else if (step.type === 'DELAY') { } 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') { } else if (step.type === 'CONDITION') {
// Parse the value based on type // Parse the value based on type
let parsedValue: string | number | boolean = conditionValue; let parsedValue: string | number | boolean = conditionValue;
@@ -1467,16 +1664,13 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
return; return;
} }
let headers = {}; // Convert headers array to object, filtering out empty entries
if (webhookHeaders.trim()) { const headers: Record<string, string> = {};
try { webhookHeaders.forEach(header => {
headers = JSON.parse(webhookHeaders); if (header.key.trim() && header.value.trim()) {
} catch { headers[header.key.trim()] = header.value.trim();
toast.error('Invalid JSON in webhook headers');
setIsSubmitting(false);
return;
} }
} });
newConfig = { newConfig = {
url: webhookUrl, url: webhookUrl,
@@ -1504,9 +1698,32 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
setIsSubmitting(false); setIsSubmitting(false);
return; 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 = { newConfig = {
eventName, eventName,
timeout: parseInt(eventTimeout), timeout: timeoutSeconds,
}; };
} }
@@ -1536,6 +1753,20 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Step</DialogTitle> <DialogTitle>Edit Step</DialogTitle>
<div className="flex items-center gap-2 mt-2">
<div className="w-6 h-6 rounded flex items-center justify-center" style={{backgroundColor: bgColor}}>
<Icon className="h-3.5 w-3.5" style={{color}} />
</div>
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{
backgroundColor: bgColor,
color,
}}
>
{step.type.replace(/_/g, ' ')}
</span>
</div>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
@@ -1550,12 +1781,6 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
/> />
</div> </div>
<div className="px-3 py-2 bg-neutral-50 rounded-lg border border-neutral-200">
<p className="text-sm text-neutral-600">
Type: <strong className="text-neutral-900">{step.type}</strong>
</p>
</div>
{/* SEND_EMAIL Configuration */} {/* SEND_EMAIL Configuration */}
{step.type === 'SEND_EMAIL' && ( {step.type === 'SEND_EMAIL' && (
<div> <div>
@@ -1577,30 +1802,44 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* DELAY Configuration */} {/* DELAY Configuration */}
{step.type === 'DELAY' && ( {step.type === 'DELAY' && (
<div className="grid grid-cols-2 gap-4"> <div className="space-y-4">
<div> <div className="grid grid-cols-2 gap-4">
<Label htmlFor="editDelayAmount">Amount *</Label> <div>
<Input <Label htmlFor="editDelayAmount">Amount *</Label>
id="editDelayAmount" <Input
type="number" id="editDelayAmount"
value={delayAmount} type="number"
onChange={e => setDelayAmount(e.target.value)} value={delayAmount}
required onChange={e => setDelayAmount(e.target.value)}
min="1" required
/> min="1"
</div> max={
<div> delayUnit === 'minutes'
<Label htmlFor="editDelayUnit">Unit *</Label> ? 525600
<Select value={delayUnit} onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}> : delayUnit === 'hours'
<SelectTrigger id="editDelayUnit"> ? 8760
<SelectValue /> : delayUnit === 'days'
</SelectTrigger> ? 365
<SelectContent> : undefined
<SelectItem value="minutes">Minutes</SelectItem> }
<SelectItem value="hours">Hours</SelectItem> />
<SelectItem value="days">Days</SelectItem> </div>
</SelectContent> <div>
</Select> <Label htmlFor="editDelayUnit">Unit *</Label>
<Select
value={delayUnit}
onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}
>
<SelectTrigger id="editDelayUnit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
)} )}
@@ -1708,30 +1947,71 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="editEventName">Event Name *</Label> <Label htmlFor="editEventName">Event Name *</Label>
<Input {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
id="editEventName" <Select value={eventName} onValueChange={setEventName} required>
type="text" <SelectTrigger id="editEventName">
value={eventName} <SelectValue placeholder="Select an event..." />
onChange={e => setEventName(e.target.value)} </SelectTrigger>
required <SelectContent>
placeholder="e.g., email.clicked, user.upgraded" {eventNamesData.eventNames.map(name => (
/> <SelectItem key={name} value={name}>
<p className="text-xs text-neutral-500 mt-1">The event name to wait for</p> {name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="editEventName"
type="text"
value={eventName}
onChange={e => setEventName(e.target.value)}
required
placeholder="e.g., email.clicked, user.upgraded"
/>
)}
<p className="text-xs text-neutral-500 mt-1">
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0
? 'Select from previously tracked events'
: 'The event name to wait for'}
</p>
</div> </div>
<div> <div>
<Label htmlFor="editEventTimeout">Timeout (seconds)</Label> <Label htmlFor="editEventTimeoutAmount">Timeout</Label>
<Input <div className="flex gap-2">
id="editEventTimeout" <Input
type="number" id="editEventTimeoutAmount"
value={eventTimeout} type="number"
onChange={e => setEventTimeout(e.target.value)} value={eventTimeoutAmount}
placeholder="86400" onChange={e => setEventTimeoutAmount(e.target.value)}
min="0" placeholder="1"
/> min="0"
<p className="text-xs text-neutral-500 mt-1"> max={
How long to wait before continuing (0 = wait forever). Default: 86400 (24 hours) eventTimeoutUnit === 'minutes'
</p> ? 525600
: eventTimeoutUnit === 'hours'
? 8760
: eventTimeoutUnit === 'days'
? 365
: undefined
}
className="flex-1"
/>
<Select
value={eventTimeoutUnit}
onValueChange={value => setEventTimeoutUnit(value as 'minutes' | 'hours' | 'days')}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
)} )}
@@ -1739,9 +2019,58 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* WEBHOOK Configuration */} {/* WEBHOOK Configuration */}
{step.type === 'WEBHOOK' && ( {step.type === 'WEBHOOK' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Info Alert about webhook body */}
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>Request Body</AlertTitle>
<AlertDescription>
<div className="space-y-2">
<p className="text-xs">
Plunk will automatically send the following JSON payload with each webhook request:
</p>
<Collapsible open={showWebhookInfo} onOpenChange={setShowWebhookInfo}>
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-neutral-700 hover:text-neutral-900">
<ChevronDown
className={`h-3 w-3 transition-transform ${showWebhookInfo ? 'rotate-180' : ''}`}
/>
{showWebhookInfo ? 'Hide' : 'Show'} payload structure
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<pre className="text-[10px] bg-neutral-50 p-2 rounded border border-neutral-200 overflow-x-auto">
{`{
"contact": {
"email": "[email protected]",
"subscribed": true,
"data": {
// All custom contact fields
"name": "John Doe",
"plan": "premium"
}
},
"workflow": {
"id": "wf_...",
"name": "Welcome Series"
},
"execution": {
"id": "exec_...",
"startedAt": "2025-01-19T..."
},
"event": {
// Event data that triggered
// the workflow (if any)
}
}`}
</pre>
</CollapsibleContent>
</Collapsible>
</div>
</AlertDescription>
</Alert>
<div> <div>
<Label htmlFor="editWebhookUrl">Webhook URL *</Label> <Label htmlFor="editWebhookUrl">Webhook URL *</Label>
<Input <Input
className={'font-mono'}
id="editWebhookUrl" id="editWebhookUrl"
type="url" type="url"
value={webhookUrl} value={webhookUrl}
@@ -1754,7 +2083,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<div> <div>
<Label htmlFor="editWebhookMethod">HTTP Method *</Label> <Label htmlFor="editWebhookMethod">HTTP Method *</Label>
<Select value={webhookMethod} onValueChange={setWebhookMethod}> <Select value={webhookMethod} onValueChange={setWebhookMethod}>
<SelectTrigger id="editWebhookMethod"> <SelectTrigger id="editWebhookMethod" className={'font-mono'}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1768,16 +2097,72 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</div> </div>
<div> <div>
<Label htmlFor="editWebhookHeaders">Headers (JSON, optional)</Label> <div className="flex items-center justify-between mb-2">
<textarea <Label>HTTP Headers (optional)</Label>
id="editWebhookHeaders" <Button
value={webhookHeaders} type="button"
onChange={e => setWebhookHeaders(e.target.value)} variant="outline"
placeholder='{"Authorization": "Bearer token", "Content-Type": "application/json"}' size="sm"
className="w-full px-3 py-2 border border-neutral-200 rounded-lg text-sm font-mono" onClick={() => setWebhookHeaders([...webhookHeaders, {key: '', value: ''}])}
rows={3} className="h-7 text-xs"
/> >
<p className="text-xs text-neutral-500 mt-1">Optional custom headers as JSON</p> <Plus className="h-3 w-3 mr-1" />
Add Header
</Button>
</div>
{webhookHeaders.length === 0 ? (
<p className="text-xs text-neutral-500 py-2">
No custom headers. Click &quot;Add Header&quot; to include headers like Authorization.
</p>
) : (
<div className="space-y-2">
{webhookHeaders.map((header, index) => (
<div key={index} className="flex gap-2 items-start">
<div className="flex-1">
<Input
placeholder="Header name (e.g., Authorization)"
value={header.key}
onChange={e => {
const newHeaders = [...webhookHeaders];
if (newHeaders[index]) {
newHeaders[index].key = e.target.value;
}
setWebhookHeaders(newHeaders);
}}
className="text-sm font-mono"
/>
</div>
<div className="flex-1">
<Input
placeholder="Header value (e.g., Bearer token123)"
value={header.value}
onChange={e => {
const newHeaders = [...webhookHeaders];
if (newHeaders[index]) {
newHeaders[index].value = e.target.value;
}
setWebhookHeaders(newHeaders);
}}
className="text-sm font-mono"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newHeaders = webhookHeaders.filter((_, i) => i !== index);
setWebhookHeaders(newHeaders);
}}
className="h-9 w-9 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -1803,14 +2188,21 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{step.type === 'EXIT' && ( {step.type === 'EXIT' && (
<div> <div>
<Label htmlFor="editExitReason">Exit Reason</Label> <Label htmlFor="editExitReason">Exit Reason</Label>
<Input <Select value={exitReason} onValueChange={setExitReason}>
id="editExitReason" <SelectTrigger id="editExitReason">
type="text" <SelectValue placeholder="Select exit reason..." />
value={exitReason} </SelectTrigger>
onChange={e => setExitReason(e.target.value)} <SelectContent>
placeholder="e.g., unsubscribed, completed, not_eligible" <SelectItem value="completed">Completed - Contact completed the workflow successfully</SelectItem>
/> <SelectItem value="unsubscribed">Unsubscribed - Contact unsubscribed</SelectItem>
<p className="text-xs text-neutral-500 mt-1">Optional reason for exiting (for tracking/analytics)</p> <SelectItem value="not_eligible">Not Eligible - Contact doesn&apos;t meet criteria</SelectItem>
<SelectItem value="opted_out">Opted Out - Contact opted out of this workflow</SelectItem>
<SelectItem value="goal_achieved">Goal Achieved - Workflow goal was met early</SelectItem>
<SelectItem value="duplicate">Duplicate - Contact already in workflow</SelectItem>
<SelectItem value="error">Error - Technical issue occurred</SelectItem>
<SelectItem value="other">Other - Custom reason</SelectItem>
</SelectContent>
</Select>
</div> </div>
)} )}
+26 -5
View File
@@ -200,13 +200,34 @@ export const WorkflowSchemas = {
}; };
export const WorkflowStepConfigSchemas = { export const WorkflowStepConfigSchemas = {
delay: z.object({ delay: z
amount: z.number().positive(), .object({
unit: z.enum(['minutes', 'hours', 'days']), amount: z.number().positive(),
}), unit: z.enum(['minutes', 'hours', 'days']),
})
.refine(
data => {
// Max 365 days
const maxMinutes = 365 * 24 * 60;
const maxHours = 365 * 24;
const maxDays = 365;
if (data.unit === 'minutes') return data.amount <= maxMinutes;
if (data.unit === 'hours') return data.amount <= maxHours;
if (data.unit === 'days') return data.amount <= maxDays;
return true;
},
{
message: 'Delay cannot exceed 365 days',
},
),
waitForEvent: z.object({ waitForEvent: z.object({
eventName: z.string().min(1), eventName: z.string().min(1),
timeout: z.number().positive().optional(), timeout: z
.number()
.positive()
.max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)')
.optional(),
}), }),
condition: z.object({ condition: z.object({
field: z.string().min(1), field: z.string().min(1),