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
+465 -73
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,7 +470,8 @@ export default function WorkflowEditorPage() {
)} )}
{/* Validation Warning Banner */} {/* Validation Warning Banner */}
{!workflow.enabled && (() => { {!workflow.enabled &&
(() => {
const validation = validateWorkflow(workflow); const validation = validateWorkflow(workflow);
if (!validation.valid) { if (!validation.valid) {
return ( return (
@@ -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,6 +1187,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* DELAY Configuration */} {/* DELAY Configuration */}
{type === 'DELAY' && ( {type === 'DELAY' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="delayAmount">Amount *</Label> <Label htmlFor="delayAmount">Amount *</Label>
@@ -1102,11 +1198,23 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
onChange={e => setDelayAmount(e.target.value)} onChange={e => setDelayAmount(e.target.value)}
required required
min="1" min="1"
max={
delayUnit === 'minutes'
? 525600
: delayUnit === 'hours'
? 8760
: delayUnit === 'days'
? 365
: undefined
}
/> />
</div> </div>
<div> <div>
<Label htmlFor="delayUnit">Unit *</Label> <Label htmlFor="delayUnit">Unit *</Label>
<Select value={delayUnit} onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}> <Select
value={delayUnit}
onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}
>
<SelectTrigger id="delayUnit"> <SelectTrigger id="delayUnit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -1118,6 +1226,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
</Select> </Select>
</div> </div>
</div> </div>
</div>
)} )}
{/* CONDITION Configuration */} {/* CONDITION Configuration */}
@@ -1223,6 +1332,20 @@ 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>
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
<Select value={eventName} onValueChange={setEventName} required>
<SelectTrigger id="eventName">
<SelectValue placeholder="Select an event..." />
</SelectTrigger>
<SelectContent>
{eventNamesData.eventNames.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
id="eventName" id="eventName"
type="text" type="text"
@@ -1231,22 +1354,49 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
required required
placeholder="e.g., email.clicked, user.upgraded" placeholder="e.g., email.clicked, user.upgraded"
/> />
<p className="text-xs text-neutral-500 mt-1">The event name to wait for</p> )}
<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>
<div className="flex gap-2">
<Input <Input
id="eventTimeout" id="eventTimeoutAmount"
type="number" type="number"
value={eventTimeout} value={eventTimeoutAmount}
onChange={e => setEventTimeout(e.target.value)} onChange={e => setEventTimeoutAmount(e.target.value)}
placeholder="86400" placeholder="1"
min="0" min="0"
max={
eventTimeoutUnit === 'minutes'
? 525600
: eventTimeoutUnit === 'hours'
? 8760
: eventTimeoutUnit === 'days'
? 365
: undefined
}
className="flex-1"
/> />
<p className="text-xs text-neutral-500 mt-1"> <Select
How long to wait before continuing (0 = wait forever). Default: 86400 (24 hours) value={eventTimeoutUnit}
</p> 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,6 +1802,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* DELAY Configuration */} {/* DELAY Configuration */}
{step.type === 'DELAY' && ( {step.type === 'DELAY' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="editDelayAmount">Amount *</Label> <Label htmlFor="editDelayAmount">Amount *</Label>
@@ -1587,11 +1813,23 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
onChange={e => setDelayAmount(e.target.value)} onChange={e => setDelayAmount(e.target.value)}
required required
min="1" min="1"
max={
delayUnit === 'minutes'
? 525600
: delayUnit === 'hours'
? 8760
: delayUnit === 'days'
? 365
: undefined
}
/> />
</div> </div>
<div> <div>
<Label htmlFor="editDelayUnit">Unit *</Label> <Label htmlFor="editDelayUnit">Unit *</Label>
<Select value={delayUnit} onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}> <Select
value={delayUnit}
onValueChange={value => setDelayUnit(value as 'hours' | 'days' | 'minutes')}
>
<SelectTrigger id="editDelayUnit"> <SelectTrigger id="editDelayUnit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -1603,6 +1841,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</Select> </Select>
</div> </div>
</div> </div>
</div>
)} )}
{/* CONDITION Configuration */} {/* CONDITION Configuration */}
@@ -1708,6 +1947,20 @@ 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>
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
<Select value={eventName} onValueChange={setEventName} required>
<SelectTrigger id="editEventName">
<SelectValue placeholder="Select an event..." />
</SelectTrigger>
<SelectContent>
{eventNamesData.eventNames.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
id="editEventName" id="editEventName"
type="text" type="text"
@@ -1716,22 +1969,49 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
required required
placeholder="e.g., email.clicked, user.upgraded" placeholder="e.g., email.clicked, user.upgraded"
/> />
<p className="text-xs text-neutral-500 mt-1">The event name to wait for</p> )}
<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>
<div className="flex gap-2">
<Input <Input
id="editEventTimeout" id="editEventTimeoutAmount"
type="number" type="number"
value={eventTimeout} value={eventTimeoutAmount}
onChange={e => setEventTimeout(e.target.value)} onChange={e => setEventTimeoutAmount(e.target.value)}
placeholder="86400" placeholder="1"
min="0" min="0"
max={
eventTimeoutUnit === 'minutes'
? 525600
: eventTimeoutUnit === 'hours'
? 8760
: eventTimeoutUnit === 'days'
? 365
: undefined
}
className="flex-1"
/> />
<p className="text-xs text-neutral-500 mt-1"> <Select
How long to wait before continuing (0 = wait forever). Default: 86400 (24 hours) value={eventTimeoutUnit}
</p> 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"
>
<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"
/> />
<p className="text-xs text-neutral-500 mt-1">Optional custom headers as JSON</p> </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>
)} )}
+24 -3
View File
@@ -200,13 +200,34 @@ export const WorkflowSchemas = {
}; };
export const WorkflowStepConfigSchemas = { export const WorkflowStepConfigSchemas = {
delay: z.object({ delay: z
.object({
amount: z.number().positive(), amount: z.number().positive(),
unit: z.enum(['minutes', 'hours', 'days']), 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),