diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index dd0d1d4..95317ac 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -1,11 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { + Alert, + AlertDescription, + AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, ConfirmDialog, Dialog, DialogContent, @@ -19,12 +25,29 @@ import { SelectItem, SelectTrigger, SelectValue, - Switch, + Switch } from '@plunk/ui'; import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; -import {ArrowLeft, Play, Power, PowerOff, Settings, Users} from 'lucide-react'; +import { + ArrowLeft, + ChevronDown, + Clock, + GitBranch, + Info, + LogOut, + Mail, + Play, + Plus, + Power, + PowerOff, + Settings, + Trash2, + UserCog, + Users, + Webhook +} from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; @@ -53,6 +76,40 @@ interface PaginatedExecutions { totalPages: number; } +// Step type styling (matching WorkflowBuilder) +const STEP_TYPE_ICONS = { + TRIGGER: GitBranch, + SEND_EMAIL: Mail, + DELAY: Clock, + WAIT_FOR_EVENT: Clock, + CONDITION: GitBranch, + EXIT: LogOut, + WEBHOOK: Webhook, + UPDATE_CONTACT: UserCog, +}; + +const STEP_TYPE_COLORS = { + TRIGGER: '#9333ea', + SEND_EMAIL: '#2563eb', + DELAY: '#ea580c', + WAIT_FOR_EVENT: '#ca8a04', + CONDITION: '#9333ea', + EXIT: '#dc2626', + WEBHOOK: '#16a34a', + UPDATE_CONTACT: '#4f46e5', +}; + +const STEP_TYPE_BG = { + TRIGGER: '#f3e8ff', + SEND_EMAIL: '#dbeafe', + DELAY: '#ffedd5', + WAIT_FOR_EVENT: '#fef3c7', + CONDITION: '#f3e8ff', + EXIT: '#fee2e2', + WEBHOOK: '#dcfce7', + UPDATE_CONTACT: '#e0e7ff', +}; + export default function WorkflowEditorPage() { const router = useRouter(); const {id} = router.query; @@ -187,7 +244,6 @@ export default function WorkflowEditorPage() { // Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT) const triggerSteps = workflow.steps.filter(s => s.type === 'TRIGGER'); - const exitSteps = workflow.steps.filter(s => s.type === 'EXIT'); workflow.steps.forEach(step => { if (step.type !== 'TRIGGER' && step.type !== 'EXIT') { @@ -414,38 +470,39 @@ export default function WorkflowEditorPage() { )} {/* Validation Warning Banner */} - {!workflow.enabled && (() => { - const validation = validateWorkflow(workflow); - if (!validation.valid) { - return ( -
-
-
- - - -
-
-

Workflow has validation errors

-
-

Fix the following issues before enabling this workflow:

-
    - {validation.errors.map((error, i) => ( -
  • {error}
  • - ))} -
+ {!workflow.enabled && + (() => { + const validation = validateWorkflow(workflow); + if (!validation.valid) { + return ( +
+
+
+ + + +
+
+

Workflow has validation errors

+
+

Fix the following issues before enabling this workflow:

+
    + {validation.errors.map((error, i) => ( +
  • {error}
  • + ))} +
+
-
- ); - } - return null; - })()} + ); + } + return null; + })()} {/* Tabs */}
@@ -862,7 +919,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo // WAIT_FOR_EVENT fields const [eventName, setEventName] = useState(''); - const [eventTimeout, setEventTimeout] = useState('86400'); + const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1'); + const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>('days'); // WEBHOOK fields const [webhookUrl, setWebhookUrl] = useState(''); @@ -880,6 +938,11 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo const {data: templatesData} = useSWR<{templates: Template[]}>('/templates?pageSize=100'); const {data: workflow} = useSWR(workflowId ? `/workflows/${workflowId}` : null); + // Fetch available event names when dialog opens + const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, { + revalidateOnFocus: false, + }); + // Fetch available fields when dialog opens and type is CONDITION useEffect(() => { const fetchAvailableFields = async () => { @@ -928,7 +991,15 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo } config = {templateId}; } else if (type === 'DELAY') { - config = {amount: parseInt(delayAmount), unit: delayUnit}; + const amount = parseInt(delayAmount); + // Validate max 365 days + const maxValues = {minutes: 525600, hours: 8760, days: 365}; + if (amount > maxValues[delayUnit]) { + toast.error(`Delay cannot exceed 365 days (${maxValues[delayUnit]} ${delayUnit})`); + setIsSubmitting(false); + return; + } + config = {amount, unit: delayUnit}; } else if (type === 'CONDITION') { // Parse the value based on type let parsedValue: string | number | boolean = conditionValue; @@ -987,9 +1058,32 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo setIsSubmitting(false); return; } + // Convert amount + unit to seconds + const amount = parseInt(eventTimeoutAmount); + // Validate max 365 days + const maxValues = {minutes: 525600, hours: 8760, days: 365}; + if (amount > maxValues[eventTimeoutUnit]) { + toast.error(`Timeout cannot exceed 365 days (${maxValues[eventTimeoutUnit]} ${eventTimeoutUnit})`); + setIsSubmitting(false); + return; + } + let timeoutSeconds = 0; + if (amount > 0) { + switch (eventTimeoutUnit) { + case 'minutes': + timeoutSeconds = amount * 60; + break; + case 'hours': + timeoutSeconds = amount * 60 * 60; + break; + case 'days': + timeoutSeconds = amount * 60 * 60 * 24; + break; + } + } config = { eventName, - timeout: parseInt(eventTimeout), + timeout: timeoutSeconds, }; } @@ -1015,7 +1109,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo setConditionValue(''); setAvailableFields([]); setEventName(''); - setEventTimeout('86400'); + setEventTimeoutAmount('1'); + setEventTimeoutUnit('days'); setWebhookUrl(''); setWebhookMethod('POST'); setWebhookHeaders(''); @@ -1092,30 +1187,44 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo {/* DELAY Configuration */} {type === 'DELAY' && ( -
-
- - setDelayAmount(e.target.value)} - required - min="1" - /> -
-
- - +
+
+
+ + setDelayAmount(e.target.value)} + required + min="1" + max={ + delayUnit === 'minutes' + ? 525600 + : delayUnit === 'hours' + ? 8760 + : delayUnit === 'days' + ? 365 + : undefined + } + /> +
+
+ + +
)} @@ -1223,30 +1332,71 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
- setEventName(e.target.value)} - required - placeholder="e.g., email.clicked, user.upgraded" - /> -

The event name to wait for

+ {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( + + ) : ( + setEventName(e.target.value)} + required + placeholder="e.g., email.clicked, user.upgraded" + /> + )} +

+ {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 + ? 'Select from previously tracked events' + : 'The event name to wait for'} +

- - setEventTimeout(e.target.value)} - placeholder="86400" - min="0" - /> -

- How long to wait before continuing (0 = wait forever). Default: 86400 (24 hours) -

+ +
+ setEventTimeoutAmount(e.target.value)} + placeholder="1" + min="0" + max={ + eventTimeoutUnit === 'minutes' + ? 525600 + : eventTimeoutUnit === 'hours' + ? 8760 + : eventTimeoutUnit === 'days' + ? 365 + : undefined + } + className="flex-1" + /> + +
)} @@ -1318,14 +1468,21 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo {type === 'EXIT' && (
- setExitReason(e.target.value)} - placeholder="e.g., unsubscribed, completed, not_eligible" - /> -

Optional reason for exiting (for tracking/analytics)

+
)} @@ -1358,6 +1515,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS const [name, setName] = useState(step.name); const [isSubmitting, setIsSubmitting] = useState(false); + // Get icon and colors for this step type + const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch; + const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280'; + const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6'; + // SEND_EMAIL fields const [templateId, setTemplateId] = useState(step.template?.id || ''); @@ -1387,12 +1549,34 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS // WAIT_FOR_EVENT fields const [eventName, setEventName] = useState(String(config?.eventName || '')); - const [eventTimeout, setEventTimeout] = useState(String(config?.timeout || '86400')); + const [eventTimeoutAmount, setEventTimeoutAmount] = useState(() => { + const timeoutSeconds = Number(config?.timeout) || 86400; + // Convert seconds to most appropriate unit + if (timeoutSeconds === 0) return '0'; + if (timeoutSeconds % (60 * 60 * 24) === 0) return String(timeoutSeconds / (60 * 60 * 24)); + if (timeoutSeconds % (60 * 60) === 0) return String(timeoutSeconds / (60 * 60)); + if (timeoutSeconds % 60 === 0) return String(timeoutSeconds / 60); + // Default to hours if not evenly divisible + return String(Math.round(timeoutSeconds / (60 * 60))); + }); + const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>(() => { + const timeoutSeconds = Number(config?.timeout) || 86400; + if (timeoutSeconds === 0) return 'days'; + if (timeoutSeconds % (60 * 60 * 24) === 0) return 'days'; + if (timeoutSeconds % (60 * 60) === 0) return 'hours'; + return 'minutes'; + }); // WEBHOOK fields const [webhookUrl, setWebhookUrl] = useState(String(config?.url || '')); const [webhookMethod, setWebhookMethod] = useState(String(config?.method || 'POST')); - const [webhookHeaders, setWebhookHeaders] = useState(config?.headers ? JSON.stringify(config.headers, null, 2) : ''); + const [webhookHeaders, setWebhookHeaders] = useState<{key: string; value: string}[]>(() => { + if (config?.headers && typeof config.headers === 'object') { + return Object.entries(config.headers).map(([key, value]) => ({key, value: String(value)})); + } + return []; + }); + const [showWebhookInfo, setShowWebhookInfo] = useState(false); // UPDATE_CONTACT fields const [contactUpdates, setContactUpdates] = useState(config?.updates ? JSON.stringify(config.updates, null, 2) : ''); @@ -1403,6 +1587,11 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS const {data: templatesData} = useSWR<{templates: Template[]}>('/templates?pageSize=100'); const {data: workflow} = useSWR(workflowId ? `/workflows/${workflowId}` : null); + // Fetch available event names when dialog opens + const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, { + revalidateOnFocus: false, + }); + // Fetch available contact fields when dialog opens and type is CONDITION useEffect(() => { const fetchAvailableFields = async () => { @@ -1445,7 +1634,15 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS } newConfig = {templateId}; } else if (step.type === 'DELAY') { - newConfig = {amount: parseInt(delayAmount), unit: delayUnit}; + const amount = parseInt(delayAmount); + // Validate max 365 days + const maxValues = {minutes: 525600, hours: 8760, days: 365}; + if (amount > maxValues[delayUnit]) { + toast.error(`Delay cannot exceed 365 days (${maxValues[delayUnit]} ${delayUnit})`); + setIsSubmitting(false); + return; + } + newConfig = {amount, unit: delayUnit}; } else if (step.type === 'CONDITION') { // Parse the value based on type let parsedValue: string | number | boolean = conditionValue; @@ -1467,16 +1664,13 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS return; } - let headers = {}; - if (webhookHeaders.trim()) { - try { - headers = JSON.parse(webhookHeaders); - } catch { - toast.error('Invalid JSON in webhook headers'); - setIsSubmitting(false); - return; + // Convert headers array to object, filtering out empty entries + const headers: Record = {}; + webhookHeaders.forEach(header => { + if (header.key.trim() && header.value.trim()) { + headers[header.key.trim()] = header.value.trim(); } - } + }); newConfig = { url: webhookUrl, @@ -1504,9 +1698,32 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS setIsSubmitting(false); return; } + // Convert amount + unit to seconds + const amount = parseInt(eventTimeoutAmount); + // Validate max 365 days + const maxValues = {minutes: 525600, hours: 8760, days: 365}; + if (amount > maxValues[eventTimeoutUnit]) { + toast.error(`Timeout cannot exceed 365 days (${maxValues[eventTimeoutUnit]} ${eventTimeoutUnit})`); + setIsSubmitting(false); + return; + } + let timeoutSeconds = 0; + if (amount > 0) { + switch (eventTimeoutUnit) { + case 'minutes': + timeoutSeconds = amount * 60; + break; + case 'hours': + timeoutSeconds = amount * 60 * 60; + break; + case 'days': + timeoutSeconds = amount * 60 * 60 * 24; + break; + } + } newConfig = { eventName, - timeout: parseInt(eventTimeout), + timeout: timeoutSeconds, }; } @@ -1536,6 +1753,20 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS Edit Step +
+
+ +
+ + {step.type.replace(/_/g, ' ')} + +
@@ -1550,12 +1781,6 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS />
-
-

- Type: {step.type} -

-
- {/* SEND_EMAIL Configuration */} {step.type === 'SEND_EMAIL' && (
@@ -1577,30 +1802,44 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS {/* DELAY Configuration */} {step.type === 'DELAY' && ( -
-
- - setDelayAmount(e.target.value)} - required - min="1" - /> -
-
- - +
+
+
+ + setDelayAmount(e.target.value)} + required + min="1" + max={ + delayUnit === 'minutes' + ? 525600 + : delayUnit === 'hours' + ? 8760 + : delayUnit === 'days' + ? 365 + : undefined + } + /> +
+
+ + +
)} @@ -1708,30 +1947,71 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
- setEventName(e.target.value)} - required - placeholder="e.g., email.clicked, user.upgraded" - /> -

The event name to wait for

+ {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( + + ) : ( + setEventName(e.target.value)} + required + placeholder="e.g., email.clicked, user.upgraded" + /> + )} +

+ {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 + ? 'Select from previously tracked events' + : 'The event name to wait for'} +

- - setEventTimeout(e.target.value)} - placeholder="86400" - min="0" - /> -

- How long to wait before continuing (0 = wait forever). Default: 86400 (24 hours) -

+ +
+ setEventTimeoutAmount(e.target.value)} + placeholder="1" + min="0" + max={ + eventTimeoutUnit === 'minutes' + ? 525600 + : eventTimeoutUnit === 'hours' + ? 8760 + : eventTimeoutUnit === 'days' + ? 365 + : undefined + } + className="flex-1" + /> + +
)} @@ -1739,9 +2019,58 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS {/* WEBHOOK Configuration */} {step.type === 'WEBHOOK' && (
+ {/* Info Alert about webhook body */} + + + Request Body + +
+

+ Plunk will automatically send the following JSON payload with each webhook request: +

+ + + + {showWebhookInfo ? 'Hide' : 'Show'} payload structure + + +
+                          {`{
+  "contact": {
+    "email": "user@example.com",
+    "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)
+  }
+}`}
+                        
+
+
+
+
+
+