Improvement to workflow creation
This commit is contained in:
@@ -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'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 "Add Header" 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'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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user