diff --git a/apps/web/src/components/workflow-steps/ConditionStepDialog.tsx b/apps/web/src/components/workflow-steps/ConditionStepDialog.tsx new file mode 100644 index 0000000..902fc8a --- /dev/null +++ b/apps/web/src/components/workflow-steps/ConditionStepDialog.tsx @@ -0,0 +1,613 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {Button, IconSpinner, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui'; +import {AlertTriangle, Info, Plus, Trash2} from 'lucide-react'; +import {useMemo, useState} from 'react'; +import {toast} from 'sonner'; +import useSWR from 'swr'; + +import {type EditStepDialogProps, getStepConfig, type StepWithTemplate, StepDialogShell, useStepUpdate} from './shared'; + +type FieldType = 'string' | 'number' | 'boolean' | 'date'; +type ConditionMode = 'binary' | 'multi'; + +interface AvailableField { + field: string; + type: string; + category: string; +} + +interface BranchInput { + id: string; + name: string; + operator: string; + value: string; +} + +interface OperatorOption { + value: string; + label: string; + types: FieldType[]; +} + +const ALL_OPERATORS: OperatorOption[] = [ + {value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']}, + {value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']}, + {value: 'contains', label: 'Contains', types: ['string']}, + {value: 'notContains', label: 'Does not contain', types: ['string']}, + {value: 'greaterThan', label: 'Greater than', types: ['number', 'date']}, + {value: 'lessThan', label: 'Less than', types: ['number', 'date']}, + {value: 'greaterThanOrEqual', label: 'Greater than or equal', types: ['number', 'date']}, + {value: 'lessThanOrEqual', label: 'Less than or equal', types: ['number', 'date']}, + {value: 'exists', label: 'Exists', types: ['string', 'number', 'boolean', 'date']}, + {value: 'notExists', label: 'Does not exist', types: ['string', 'number', 'boolean', 'date']}, +]; + +const NO_VALUE_OPERATORS = ['exists', 'notExists']; + +function getOperatorsForType(fieldType: string): OperatorOption[] { + return ALL_OPERATORS.filter(op => op.types.includes(fieldType as FieldType)); +} + +function extractInitialField(rawField: unknown): string { + if (!rawField) return ''; + if (typeof rawField === 'object' && rawField !== null && 'field' in rawField) { + return String((rawField as {field?: unknown}).field ?? ''); + } + return String(rawField); +} + +function extractInitialBranches(config: Record): BranchInput[] { + if (config.mode === 'multi' && Array.isArray(config.branches)) { + return (config.branches as any[]).map(b => ({ + id: String(b.id ?? crypto.randomUUID().slice(0, 8)), + name: String(b.name ?? ''), + operator: String(b.operator ?? 'equals'), + value: String(b.value ?? ''), + })); + } + return [{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''}]; +} + +function parseConditionValue(value: string): string | number | boolean { + if (value === 'true') return true; + if (value === 'false') return false; + if (value !== '' && !isNaN(Number(value))) return Number(value); + return value; +} + +function hasMultiBranchConnections(step: StepWithTemplate): boolean { + if (step.type !== 'CONDITION') return false; + const config = getStepConfig(step); + if (config.mode !== 'multi') return false; + + const transitions = (step as any).outgoingTransitions ?? []; + return transitions.some((t: any) => { + const condition = t.condition; + if (condition && typeof condition === 'object' && 'branch' in condition) { + const branch = String(condition.branch); + return branch !== 'yes' && branch !== 'no'; + } + return false; + }); +} + +function hasBinaryConnections(step: StepWithTemplate): boolean { + if (step.type !== 'CONDITION') return false; + const config = getStepConfig(step); + if (config.mode === 'multi') return false; + + const transitions = (step as any).outgoingTransitions ?? []; + return transitions.some((t: any) => { + const condition = t.condition; + if (condition && typeof condition === 'object' && 'branch' in condition) { + const branch = String(condition.branch); + return branch === 'yes' || branch === 'no'; + } + return false; + }); +} + +export function ConditionStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { + const config = getStepConfig(step); + + const [name, setName] = useState(step.name); + const [conditionMode, setConditionMode] = useState(config.mode === 'multi' ? 'multi' : 'binary'); + const [conditionField, setConditionField] = useState(extractInitialField(config.field)); + const [conditionOperator, setConditionOperator] = useState(String(config.operator ?? 'equals')); + const [conditionValue, setConditionValue] = useState(String(config.value ?? '')); + const [conditionBranches, setConditionBranches] = useState(() => extractInitialBranches(config)); + + const {data: workflow} = useSWR<{triggerConfig: {eventName?: string} | null}>( + workflowId ? `/workflows/${workflowId}` : null, + ); + + const triggerEventName = workflow?.triggerConfig?.eventName; + const fieldsUrl = open + ? triggerEventName + ? `/workflows/fields?eventName=${encodeURIComponent(triggerEventName)}` + : '/workflows/fields' + : null; + + const {data: fieldsData, isLoading: loadingFields} = useSWR<{fields: string[]; typedFields: AvailableField[]}>( + fieldsUrl, + {revalidateOnFocus: false}, + ); + + const availableFields: AvailableField[] = useMemo(() => { + if (!fieldsData) return []; + return ( + fieldsData.typedFields ?? + fieldsData.fields.map(f => ({field: f, type: 'string', category: 'Unknown'})) + ); + }, [fieldsData]); + + const {update, isSubmitting} = useStepUpdate(workflowId, step.id); + + const blocksMultiToBinary = hasMultiBranchConnections(step); + const blocksBinaryToMulti = hasBinaryConnections(step); + + const currentFieldType = availableFields.find(f => f.field === conditionField)?.type ?? 'string'; + const validOperators = useMemo(() => getOperatorsForType(currentFieldType), [currentFieldType]); + const needsValue = !NO_VALUE_OPERATORS.includes(conditionOperator); + + const handleModeChange = (newMode: ConditionMode) => { + if (conditionMode === 'multi' && newMode === 'binary' && blocksMultiToBinary) return; + if (conditionMode === 'binary' && newMode === 'multi' && blocksBinaryToMulti) return; + setConditionMode(newMode); + }; + + const handleConditionFieldChange = (newField: string) => { + const newFieldType = availableFields.find(f => f.field === newField)?.type ?? 'string'; + const newValidOperators = getOperatorsForType(newFieldType); + + setConditionField(newField); + + if (!newValidOperators.some(op => op.value === conditionOperator)) { + setConditionOperator('equals'); + } + + if (newFieldType === 'boolean') { + setConditionValue('true'); + } else if (currentFieldType === 'boolean' && newFieldType !== 'boolean') { + setConditionValue(''); + } + }; + + const updateBranch = (id: string, patch: Partial) => { + setConditionBranches(prev => prev.map(b => (b.id === id ? {...b, ...patch} : b))); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + let newConfig: Record; + + if (conditionMode === 'multi') { + const validBranches = conditionBranches.filter(b => b.name.trim()); + if (validBranches.length === 0) { + toast.error('At least one branch with a name is required'); + return; + } + if (!conditionField) { + toast.error('Please select a field'); + return; + } + + newConfig = { + mode: 'multi' as const, + field: conditionField, + branches: validBranches.map(b => ({ + id: b.id, + name: b.name.trim(), + operator: b.operator, + value: parseConditionValue(b.value), + })), + }; + } else { + newConfig = { + field: conditionField, + operator: conditionOperator, + value: parseConditionValue(conditionValue), + }; + } + + const ok = await update({name, config: newConfig}); + + if (ok) { + onOpenChange(false); + onSuccess(); + } + }; + + const initialMode: ConditionMode = config.mode === 'multi' ? 'multi' : 'binary'; + const showWiringWarning = + conditionMode !== initialMode && + !blocksMultiToBinary && + !blocksBinaryToMulti && + Array.isArray((step as any).outgoingTransitions) && + ((step as any).outgoingTransitions as unknown[]).length > 0; + + return ( + +
+
+ + + + + {conditionMode === 'binary' && ( + + )} + + {conditionMode === 'multi' && ( + setConditionBranches(prev => prev.filter(b => b.id !== id))} + onAddBranch={() => + setConditionBranches(prev => [ + ...prev, + {id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''}, + ]) + } + /> + )} +
+
+
+ ); +} + +interface ConditionModeToggleProps { + mode: ConditionMode; + onChange: (mode: ConditionMode) => void; + blocksMultiToBinary: boolean; + blocksBinaryToMulti: boolean; + showWiringWarning: boolean; +} + +function ConditionModeToggle({ + mode, + onChange, + blocksMultiToBinary, + blocksBinaryToMulti, + showWiringWarning, +}: ConditionModeToggleProps) { + return ( +
+ +
+ + +
+

+ {mode === 'binary' + ? 'Evaluates a single condition with Yes/No paths' + : 'Match a field against multiple values, each routing to its own branch'} +

+ {blocksMultiToBinary && ( +
+ + Cannot switch to simple mode: branches have connected nodes. Disconnect all branch connections first. +
+ )} + {blocksBinaryToMulti && ( +
+ + Cannot switch to multi-branch mode: Yes/No branches have connected nodes. Disconnect all branch connections + first. +
+ )} + {showWiringWarning && ( +
+ + Changing mode will disconnect existing branches. You will need to rewire them. +
+ )} +
+ ); +} + +interface ConditionFieldPickerProps { + value: string; + onChange: (value: string) => void; + availableFields: AvailableField[]; + loading: boolean; +} + +function ConditionFieldPicker({value, onChange, availableFields, loading}: ConditionFieldPickerProps) { + const grouped = useMemo(() => { + return availableFields.reduce>((acc, field) => { + if (!acc[field.category]) acc[field.category] = []; + acc[field.category]!.push(field); + return acc; + }, {}); + }, [availableFields]); + + return ( +
+ + {loading ? ( +
+ + Loading fields... +
+ ) : availableFields.length > 0 ? ( + + ) : ( + onChange(e.target.value)} + required + placeholder="e.g., contact.subscribed or contact.data.plan" + className="mt-1.5" + /> + )} +
+ ); +} + +interface BinaryConditionProps { + operator: string; + value: string; + onOperatorChange: (value: string) => void; + onValueChange: (value: string) => void; + validOperators: OperatorOption[]; + fieldType: string; + needsValue: boolean; +} + +function BinaryCondition({ + operator, + value, + onOperatorChange, + onValueChange, + validOperators, + fieldType, + needsValue, +}: BinaryConditionProps) { + return ( + <> +
+ + +
+ + {needsValue && ( +
+ + +
+ )} + + ); +} + +interface ConditionValueInputProps { + fieldType: string; + value: string; + onChange: (value: string) => void; +} + +function ConditionValueInput({fieldType, value, onChange}: ConditionValueInputProps) { + if (fieldType === 'boolean') { + return ( + + ); + } + + const inputType = fieldType === 'number' ? 'number' : fieldType === 'date' ? 'datetime-local' : 'text'; + const placeholder = + fieldType === 'number' ? 'e.g., 100' : fieldType === 'date' ? '' : 'e.g., premium, active'; + + return ( + onChange(e.target.value)} + required + placeholder={placeholder} + className="mt-1.5" + /> + ); +} + +interface MultiBranchEditorProps { + branches: BranchInput[]; + validOperators: OperatorOption[]; + onUpdateBranch: (id: string, patch: Partial) => void; + onRemoveBranch: (id: string) => void; + onAddBranch: () => void; +} + +function MultiBranchEditor({branches, validOperators, onUpdateBranch, onRemoveBranch, onAddBranch}: MultiBranchEditorProps) { + return ( +
+ +

+ Each branch defines a condition. The first matching branch is taken. Contacts not matching any branch follow the + Default path. +

+ + {branches.map((branch, idx) => ( +
+
+ Branch {idx + 1} + {branches.length > 1 && ( + + )} +
+ +
+ + onUpdateBranch(branch.id, {name: e.target.value})} + placeholder="e.g., Premium, Free, Enterprise" + className="mt-1 h-8 text-sm" + /> +
+ +
+
+ + +
+ + {!NO_VALUE_OPERATORS.includes(branch.operator) && ( +
+ + onUpdateBranch(branch.id, {value: e.target.value})} + placeholder="Value..." + className="mt-1 h-8 text-sm" + /> +
+ )} +
+
+ ))} + + {branches.length < 20 && ( + + )} + +
+ + + Branches are evaluated in order. The first match wins. Contacts not matching any branch will follow the{' '} + Default path. + +
+
+ ); +} diff --git a/apps/web/src/components/workflow-steps/DelayStepDialog.tsx b/apps/web/src/components/workflow-steps/DelayStepDialog.tsx new file mode 100644 index 0000000..c5bbaf7 --- /dev/null +++ b/apps/web/src/components/workflow-steps/DelayStepDialog.tsx @@ -0,0 +1,88 @@ +import {Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui'; +import {useState} from 'react'; +import {toast} from 'sonner'; + +import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; + +type DelayUnit = 'minutes' | 'hours' | 'days'; + +const MAX_DELAY_BY_UNIT: Record = { + minutes: 525600, + hours: 8760, + days: 365, +}; + +function isDelayUnit(value: unknown): value is DelayUnit { + return value === 'minutes' || value === 'hours' || value === 'days'; +} + +export function DelayStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { + const config = getStepConfig(step); + + const [name, setName] = useState(step.name); + const [delayAmount, setDelayAmount] = useState(String(config.amount ?? '24')); + const [delayUnit, setDelayUnit] = useState(isDelayUnit(config.unit) ? config.unit : 'hours'); + + const {update, isSubmitting} = useStepUpdate(workflowId, step.id); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const amount = parseInt(delayAmount, 10); + if (amount > MAX_DELAY_BY_UNIT[delayUnit]) { + toast.error(`Delay cannot exceed 365 days (${MAX_DELAY_BY_UNIT[delayUnit]} ${delayUnit})`); + return; + } + + const ok = await update({ + name, + config: {amount, unit: delayUnit}, + }); + + if (ok) { + onOpenChange(false); + onSuccess(); + } + }; + + return ( + +
+
+ + setDelayAmount(e.target.value)} + required + min="1" + max={MAX_DELAY_BY_UNIT[delayUnit]} + className="mt-1.5" + /> +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/workflow-steps/ExitStepDialog.tsx b/apps/web/src/components/workflow-steps/ExitStepDialog.tsx new file mode 100644 index 0000000..c55d210 --- /dev/null +++ b/apps/web/src/components/workflow-steps/ExitStepDialog.tsx @@ -0,0 +1,58 @@ +import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue} from '@plunk/ui'; +import {useState} from 'react'; + +import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; + +export function ExitStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { + const config = getStepConfig(step); + + const [name, setName] = useState(step.name); + const [exitReason, setExitReason] = useState(String(config.reason ?? 'completed')); + + const {update, isSubmitting} = useStepUpdate(workflowId, step.id); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const ok = await update({ + name, + config: {reason: exitReason}, + }); + + if (ok) { + onOpenChange(false); + onSuccess(); + } + }; + + return ( + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/workflow-steps/SendEmailStepDialog.tsx b/apps/web/src/components/workflow-steps/SendEmailStepDialog.tsx new file mode 100644 index 0000000..6c0c42d --- /dev/null +++ b/apps/web/src/components/workflow-steps/SendEmailStepDialog.tsx @@ -0,0 +1,113 @@ +import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui'; +import {useState} from 'react'; +import {toast} from 'sonner'; + +import {TemplateSearchPicker} from '../TemplateSearchPicker'; + +import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; + +export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { + const config = getStepConfig(step); + const recipient = config.recipient as {type?: string; customEmail?: string} | undefined; + + const [name, setName] = useState(step.name); + const [templateId, setTemplateId] = useState(step.template?.id ?? ''); + const [recipientType, setRecipientType] = useState<'CONTACT' | 'CUSTOM'>( + recipient?.type === 'CUSTOM' ? 'CUSTOM' : 'CONTACT', + ); + const [customEmail, setCustomEmail] = useState(recipient?.customEmail ?? ''); + + const {update, isSubmitting} = useStepUpdate(workflowId, step.id); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!templateId) { + toast.error('Please select a template'); + return; + } + + if (recipientType === 'CUSTOM') { + if (!customEmail.trim()) { + toast.error('Please enter a custom email address'); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customEmail)) { + toast.error('Please enter a valid email address'); + return; + } + } + + const ok = await update({ + name, + templateId, + config: { + templateId, + recipient: { + type: recipientType, + ...(recipientType === 'CUSTOM' && {customEmail: customEmail.trim()}), + }, + }, + }); + + if (ok) { + onOpenChange(false); + onSuccess(); + } + }; + + return ( + +
+
+ + +
+ +
+ + +
+ + {recipientType === 'CUSTOM' && ( +
+ + setCustomEmail(e.target.value)} + required + placeholder="e.g., admin@example.com" + className="mt-1.5" + /> +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/workflow-steps/UpdateContactStepDialog.tsx b/apps/web/src/components/workflow-steps/UpdateContactStepDialog.tsx new file mode 100644 index 0000000..897efde --- /dev/null +++ b/apps/web/src/components/workflow-steps/UpdateContactStepDialog.tsx @@ -0,0 +1,54 @@ +import {useState} from 'react'; +import {toast} from 'sonner'; + +import {KeyValueEditor} from '../KeyValueEditor'; + +import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; + +export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { + const config = getStepConfig(step); + const initialUpdates = + config.updates && typeof config.updates === 'object' + ? (config.updates as Record) + : null; + + const [name, setName] = useState(step.name); + const [contactUpdateData, setContactUpdateData] = useState | null>( + initialUpdates, + ); + + const {update, isSubmitting} = useStepUpdate(workflowId, step.id); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) { + toast.error('At least one field to update is required'); + return; + } + + const ok = await update({ + name, + config: {updates: contactUpdateData}, + }); + + if (ok) { + onOpenChange(false); + onSuccess(); + } + }; + + return ( + + + + ); +} diff --git a/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx b/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx new file mode 100644 index 0000000..24d9973 --- /dev/null +++ b/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx @@ -0,0 +1,187 @@ +import {Command, CommandGroup, CommandItem, CommandList, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui'; +import {useState} from 'react'; +import {toast} from 'sonner'; +import useSWR from 'swr'; + +import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; + +type TimeUnit = 'minutes' | 'hours' | 'days'; + +const SECONDS_PER_UNIT: Record = { + minutes: 60, + hours: 60 * 60, + days: 60 * 60 * 24, +}; + +const MAX_BY_UNIT: Record = { + minutes: 525600, + hours: 8760, + days: 365, +}; + +function isTimeUnit(value: unknown): value is TimeUnit { + return value === 'minutes' || value === 'hours' || value === 'days'; +} + +function deriveTimeoutAmountUnit(timeoutSeconds: number): {amount: string; unit: TimeUnit} { + if (timeoutSeconds === 0) return {amount: '0', unit: 'days'}; + if (timeoutSeconds % SECONDS_PER_UNIT.days === 0) { + return {amount: String(timeoutSeconds / SECONDS_PER_UNIT.days), unit: 'days'}; + } + if (timeoutSeconds % SECONDS_PER_UNIT.hours === 0) { + return {amount: String(timeoutSeconds / SECONDS_PER_UNIT.hours), unit: 'hours'}; + } + if (timeoutSeconds % SECONDS_PER_UNIT.minutes === 0) { + return {amount: String(timeoutSeconds / SECONDS_PER_UNIT.minutes), unit: 'minutes'}; + } + return {amount: String(Math.round(timeoutSeconds / SECONDS_PER_UNIT.hours)), unit: 'hours'}; +} + +export function WaitForEventStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { + const config = getStepConfig(step); + + const initialTimeout = deriveTimeoutAmountUnit(Number(config.timeout) || 86400); + const initialUnit: TimeUnit = isTimeUnit(initialTimeout.unit) ? initialTimeout.unit : 'days'; + + const [name, setName] = useState(step.name); + const [eventName, setEventName] = useState(String(config.eventName ?? '')); + const [eventPopoverOpen, setEventPopoverOpen] = useState(false); + const [timeoutAmount, setTimeoutAmount] = useState(initialTimeout.amount); + const [timeoutUnit, setTimeoutUnit] = useState(initialUnit); + + const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, { + revalidateOnFocus: false, + }); + + const {update, isSubmitting} = useStepUpdate(workflowId, step.id); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!eventName) { + toast.error('Event name is required'); + return; + } + + const amount = parseInt(timeoutAmount, 10); + if (amount > MAX_BY_UNIT[timeoutUnit]) { + toast.error(`Timeout cannot exceed 365 days (${MAX_BY_UNIT[timeoutUnit]} ${timeoutUnit})`); + return; + } + + const timeoutSeconds = amount > 0 ? amount * SECONDS_PER_UNIT[timeoutUnit] : 0; + + const ok = await update({ + name, + config: {eventName, timeout: timeoutSeconds}, + }); + + if (ok) { + onOpenChange(false); + onSuccess(); + } + }; + + const filteredEventNames = eventNamesData?.eventNames?.filter( + n => !eventName || n.toLowerCase().includes(eventName.toLowerCase()), + ); + const showCustomEntry = eventName.trim() && !eventNamesData?.eventNames?.some(n => n === eventName.trim()); + + return ( + +
+
+ +
+ { + setEventName(e.target.value); + setEventPopoverOpen(true); + }} + onFocus={() => setEventPopoverOpen(true)} + onBlur={() => { + setTimeout(() => setEventPopoverOpen(false), 150); + }} + required + placeholder="e.g., email.clicked, user.upgraded" + className="mt-1.5" + autoComplete="off" + /> + {eventPopoverOpen && ((eventNamesData?.eventNames?.length ?? 0) > 0 || eventName.trim()) && ( +
+ + + + {filteredEventNames?.map(n => ( + { + setEventName(n); + setEventPopoverOpen(false); + }} + > + {n} + + ))} + {showCustomEntry && ( + { + setEventName(eventName.trim()); + setEventPopoverOpen(false); + }} + > + Use “{eventName.trim()}” + + )} + + + +
+ )} +
+
+ +
+ +
+ setTimeoutAmount(e.target.value)} + placeholder="1" + min="0" + max={MAX_BY_UNIT[timeoutUnit]} + className="flex-1" + /> + +
+

If not received, the workflow continues after this time

+
+
+
+ ); +} diff --git a/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx b/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx new file mode 100644 index 0000000..b046e97 --- /dev/null +++ b/apps/web/src/components/workflow-steps/WebhookStepDialog.tsx @@ -0,0 +1,175 @@ +import {Button, Collapsible, CollapsibleContent, CollapsibleTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui'; +import {ChevronDown, Plus, Trash2} from 'lucide-react'; +import Link from 'next/link'; +import {useState} from 'react'; +import {toast} from 'sonner'; + +import {WIKI_URI} from '../../lib/constants'; + +import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; + +interface HeaderEntry { + key: string; + value: string; +} + +export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { + const config = getStepConfig(step); + + const initialHeaders: HeaderEntry[] = + config.headers && typeof config.headers === 'object' + ? Object.entries(config.headers as Record).map(([key, value]) => ({key, value: String(value)})) + : []; + + const [name, setName] = useState(step.name); + const [webhookUrl, setWebhookUrl] = useState(String(config.url ?? '')); + const [webhookMethod, setWebhookMethod] = useState(String(config.method ?? 'POST')); + const [webhookHeaders, setWebhookHeaders] = useState(initialHeaders); + const [showWebhookInfo, setShowWebhookInfo] = useState(false); + + const {update, isSubmitting} = useStepUpdate(workflowId, step.id); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!webhookUrl) { + toast.error('Webhook URL is required'); + return; + } + + const headers: Record = {}; + webhookHeaders.forEach(header => { + if (header.key.trim() && header.value.trim()) { + headers[header.key.trim()] = header.value.trim(); + } + }); + + const ok = await update({ + name, + config: {url: webhookUrl, method: webhookMethod, headers}, + }); + + if (ok) { + onOpenChange(false); + onSuccess(); + } + }; + + const updateHeader = (index: number, patch: Partial) => { + setWebhookHeaders(prev => prev.map((h, i) => (i === index ? {...h, ...patch} : h))); + }; + + return ( + +
+ +
+ + + {showWebhookInfo ? 'Hide' : 'View'} request payload + + + Webhook guide + +
+ +
+              {`{
+  "contact": { "email": "user@example.com", "subscribed": true, "data": { ... } },
+  "workflow": { "id": "wf_...", "name": "Welcome Series" },
+  "execution": { "id": "exec_...", "startedAt": "2025-01-19T..." },
+  "event": { ... }
+}`}
+            
+
+
+ +
+ + setWebhookUrl(e.target.value)} + required + placeholder="https://api.example.com/webhook" + /> +
+ +
+ + +
+ +
+
+ + +
+ +
+ {webhookHeaders.map((header, index) => ( +
+ updateHeader(index, {key: e.target.value})} + className="text-sm font-mono" + /> + updateHeader(index, {value: e.target.value})} + className="text-sm font-mono" + /> + +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/workflow-steps/index.tsx b/apps/web/src/components/workflow-steps/index.tsx new file mode 100644 index 0000000..81046e2 --- /dev/null +++ b/apps/web/src/components/workflow-steps/index.tsx @@ -0,0 +1,29 @@ +import {ConditionStepDialog} from './ConditionStepDialog'; +import {DelayStepDialog} from './DelayStepDialog'; +import {ExitStepDialog} from './ExitStepDialog'; +import {SendEmailStepDialog} from './SendEmailStepDialog'; +import {UpdateContactStepDialog} from './UpdateContactStepDialog'; +import {WaitForEventStepDialog} from './WaitForEventStepDialog'; +import {WebhookStepDialog} from './WebhookStepDialog'; +import {type EditStepDialogProps} from './shared'; + +export function EditStepDialog(props: EditStepDialogProps) { + switch (props.step.type) { + case 'SEND_EMAIL': + return ; + case 'DELAY': + return ; + case 'CONDITION': + return ; + case 'WAIT_FOR_EVENT': + return ; + case 'WEBHOOK': + return ; + case 'UPDATE_CONTACT': + return ; + case 'EXIT': + return ; + default: + return null; + } +} diff --git a/apps/web/src/components/workflow-steps/shared.tsx b/apps/web/src/components/workflow-steps/shared.tsx new file mode 100644 index 0000000..97aad05 --- /dev/null +++ b/apps/web/src/components/workflow-steps/shared.tsx @@ -0,0 +1,137 @@ +import {Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label} from '@plunk/ui'; +import type {WorkflowStep} from '@plunk/db'; +import {WorkflowSchemas} from '@plunk/shared'; +import {useState} from 'react'; +import {toast} from 'sonner'; + +import {network} from '../../lib/network'; + +export const STEP_TYPE_LABELS: Record = { + TRIGGER: 'Trigger', + SEND_EMAIL: 'Send Email', + DELAY: 'Delay', + WAIT_FOR_EVENT: 'Wait for Event', + CONDITION: 'Condition', + EXIT: 'Exit', + WEBHOOK: 'Webhook', + UPDATE_CONTACT: 'Update Contact', +}; + +export const STEP_TYPE_DESCRIPTIONS: Record = { + TRIGGER: 'Starts the workflow when a specific event is received.', + SEND_EMAIL: 'Sends an email to the contact using a template you choose.', + DELAY: 'Pauses the workflow for a set amount of time before continuing.', + WAIT_FOR_EVENT: 'Waits until the contact triggers a specific event, then continues.', + CONDITION: 'Splits the flow based on contact data — each path leads to different steps.', + EXIT: 'Ends the workflow for the contact.', + WEBHOOK: "Makes an HTTP request to an external URL with the contact's data.", + UPDATE_CONTACT: "Sets or updates fields on the contact's profile.", +}; + +export type StepWithTemplate = WorkflowStep & { + template?: {id: string; name: string} | null; +}; + +export interface EditStepDialogProps { + step: StepWithTemplate; + workflowId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +export interface UpdateStepInput { + name: string; + config: Record; + templateId?: string; +} + +export function useStepUpdate(workflowId: string, stepId: string) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const update = async (input: UpdateStepInput): Promise => { + setIsSubmitting(true); + try { + await network.fetch( + 'PATCH', + `/workflows/${workflowId}/steps/${stepId}`, + input as Parameters[2], + ); + toast.success('Step updated successfully'); + return true; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update step'); + return false; + } finally { + setIsSubmitting(false); + } + }; + + return {update, isSubmitting}; +} + +export function getStepConfig(step: {config: unknown}): Record { + return step.config && typeof step.config === 'object' && !Array.isArray(step.config) + ? (step.config as Record) + : {}; +} + +interface StepDialogShellProps { + step: StepWithTemplate; + open: boolean; + onOpenChange: (open: boolean) => void; + name: string; + onNameChange: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + isSubmitting: boolean; + children: React.ReactNode; +} + +export function StepDialogShell({ + step, + open, + onOpenChange, + name, + onNameChange, + onSubmit, + isSubmitting, + children, +}: StepDialogShellProps) { + return ( + + + + Edit {STEP_TYPE_LABELS[step.type] ?? step.type} + {STEP_TYPE_DESCRIPTIONS[step.type] && ( +

{STEP_TYPE_DESCRIPTIONS[step.type]}

+ )} +
+
+
+ + onNameChange(e.target.value)} + required + placeholder="e.g., Send Welcome Email" + className="mt-1.5" + /> +
+ + {children} + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 10fdf59..1ffdcf7 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -10,9 +10,6 @@ import { CardDescription, CardHeader, CardTitle, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, ConfirmDialog, Dialog, DialogContent, @@ -22,12 +19,6 @@ import { EmptyState, Input, Label, - Select, - SelectContent, - SelectItem, - SelectItemWithDescription, - SelectTrigger, - SelectValue, Command, CommandGroup, CommandItem, @@ -35,27 +26,18 @@ import { IconSpinner, Switch, } from '@plunk/ui'; -import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; -import type {PaginatedResponse} from '@plunk/types'; +import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; import { AlertTriangle, ArrowLeft, - ChevronDown, - Clock, - GitBranch, Info, - LogOut, - Mail, - Plus, Power, PowerOff, Settings, Trash2, - UserCog, Users, - Webhook, } from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; @@ -64,12 +46,10 @@ import {toast} from 'sonner'; import useSWR from 'swr'; import {NextSeo} from 'next-seo'; import {WorkflowBuilder} from '../../components/WorkflowBuilder'; -import {KeyValueEditor} from '../../components/KeyValueEditor'; -import {TemplateSearchPicker} from '../../components/TemplateSearchPicker'; +import {EditStepDialog} from '../../components/workflow-steps'; import {ReactFlowProvider} from '@xyflow/react'; import {WorkflowSchemas} from '@plunk/shared'; import dayjs from 'dayjs'; -import {WIKI_URI} from '../../lib/constants'; interface WorkflowWithDetails extends Workflow { steps: (WorkflowStep & { @@ -90,62 +70,6 @@ interface PaginatedExecutions { totalPages: number; } -// Step type styling (matching WorkflowBuilder) -const STEP_TYPE_LABELS: Record = { - TRIGGER: 'Trigger', - SEND_EMAIL: 'Send Email', - DELAY: 'Delay', - WAIT_FOR_EVENT: 'Wait for Event', - CONDITION: 'Condition', - EXIT: 'Exit', - WEBHOOK: 'Webhook', - UPDATE_CONTACT: 'Update Contact', -}; - -const STEP_TYPE_DESCRIPTIONS: Record = { - TRIGGER: 'Starts the workflow when a specific event is received.', - SEND_EMAIL: 'Sends an email to the contact using a template you choose.', - DELAY: 'Pauses the workflow for a set amount of time before continuing.', - WAIT_FOR_EVENT: 'Waits until the contact triggers a specific event, then continues.', - CONDITION: 'Splits the flow based on contact data — each path leads to different steps.', - EXIT: 'Ends the workflow for the contact.', - WEBHOOK: 'Makes an HTTP request to an external URL with the contact\'s data.', - UPDATE_CONTACT: 'Sets or updates fields on the contact\'s profile.', -}; - -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; @@ -959,1877 +883,3 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr ); } -// Add Step Dialog Component -interface AddStepDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - workflowId: string; - onSuccess: () => void; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialogProps) { - const [type, setType] = useState('SEND_EMAIL'); - const [name, setName] = useState(''); - - // SEND_EMAIL fields - const [templateId, setTemplateId] = useState(''); - const [recipientType, setRecipientType] = useState<'CONTACT' | 'CUSTOM'>('CONTACT'); - const [customEmail, setCustomEmail] = useState(''); - - // DELAY fields - const [delayAmount, setDelayAmount] = useState('24'); - const [delayUnit, setDelayUnit] = useState<'hours' | 'days' | 'minutes'>('hours'); - - // CONDITION fields - const [conditionField, setConditionField] = useState(''); - const [conditionOperator, setConditionOperator] = useState('equals'); - const [conditionValue, setConditionValue] = useState(''); - const [availableFields, setAvailableFields] = useState>([]); - const [loadingFields, setLoadingFields] = useState(false); - - // Get current field type for smart operator filtering - const currentFieldType = availableFields.find(f => f.field === conditionField)?.type || 'string'; - - // Get valid operators based on field type - const getOperatorsForType = (fieldType: string) => { - const allOperators = [ - {value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']}, - {value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']}, - {value: 'contains', label: 'Contains', types: ['string']}, - {value: 'notContains', label: 'Does not contain', types: ['string']}, - {value: 'greaterThan', label: 'Greater than', types: ['number', 'date']}, - {value: 'lessThan', label: 'Less than', types: ['number', 'date']}, - {value: 'greaterThanOrEqual', label: 'Greater than or equal', types: ['number', 'date']}, - {value: 'lessThanOrEqual', label: 'Less than or equal', types: ['number', 'date']}, - {value: 'exists', label: 'Exists', types: ['string', 'number', 'boolean', 'date']}, - {value: 'notExists', label: 'Does not exist', types: ['string', 'number', 'boolean', 'date']}, - ]; - return allOperators.filter(op => op.types.includes(fieldType)); - }; - - const validOperators = getOperatorsForType(currentFieldType); - const needsValue = !['exists', 'notExists'].includes(conditionOperator); - - // WAIT_FOR_EVENT fields - const [eventName, setEventName] = useState(''); - const [eventPopoverOpen, setEventPopoverOpen] = useState(false); - const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1'); - const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>('days'); - - // WEBHOOK fields - const [webhookUrl, setWebhookUrl] = useState(''); - const [webhookMethod, setWebhookMethod] = useState('POST'); - const [webhookHeaders, setWebhookHeaders] = useState(''); - - // UPDATE_CONTACT fields - const [contactUpdateData, setContactUpdateData] = useState | null>(null); - - // EXIT fields - const [exitReason, setExitReason] = useState('completed'); - - const [isSubmitting, setIsSubmitting] = useState(false); - - // templates fetched on-demand by TemplateSearchPicker - 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 () => { - if (type === 'CONDITION' && open && workflow) { - setLoadingFields(true); - try { - // Get event name from workflow trigger config - const triggerConfig = workflow.triggerConfig as {eventName?: string} | null; - const eventName = triggerConfig?.eventName; - - // Pass eventName as query param to filter event fields - const url = eventName ? `/workflows/fields?eventName=${encodeURIComponent(eventName)}` : '/workflows/fields'; - const response = await network.fetch<{ - fields: string[]; - typedFields: Array<{field: string; type: string; category: string}>; - }>('GET', url); - setAvailableFields( - response.typedFields || response.fields.map(f => ({field: f, type: 'string', category: 'Unknown'})), - ); - - // Set default field if available - if (response.typedFields && response.typedFields.length > 0 && !conditionField) { - setConditionField(response.typedFields[0]!.field); - } - } catch (error) { - console.error('Failed to fetch available fields:', error); - setAvailableFields([]); - } finally { - setLoadingFields(false); - } - } - }; - - void fetchAvailableFields(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [type, open, workflow]); - - // Handle condition field change - reset operator if not valid for new type - const handleConditionFieldChange = (newField: string) => { - const newFieldType = availableFields.find(f => f.field === newField)?.type || 'string'; - const newValidOperators = getOperatorsForType(newFieldType); - - setConditionField(newField); - - // Reset operator if current one is not valid for new field type - if (!newValidOperators.some(op => op.value === conditionOperator)) { - setConditionOperator('equals'); - } - - // Reset value when switching to boolean - if (newFieldType === 'boolean') { - setConditionValue('true'); - } else if (currentFieldType === 'boolean' && newFieldType !== 'boolean') { - setConditionValue(''); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - - try { - // Build step config based on type - let config: Record = {}; - - if (type === 'SEND_EMAIL') { - if (!templateId) { - toast.error('Please select a template'); - setIsSubmitting(false); - return; - } - - // Validate custom email if recipient type is CUSTOM - if (recipientType === 'CUSTOM') { - if (!customEmail || !customEmail.trim()) { - toast.error('Please enter a custom email address'); - setIsSubmitting(false); - return; - } - // Basic email validation - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customEmail)) { - toast.error('Please enter a valid email address'); - setIsSubmitting(false); - return; - } - } - - config = { - templateId, - recipient: { - type: recipientType, - ...(recipientType === 'CUSTOM' && {customEmail: customEmail.trim()}), - }, - }; - } else if (type === 'DELAY') { - 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; - if (conditionValue === 'true') parsedValue = true; - else if (conditionValue === 'false') parsedValue = false; - else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue); - - config = { - field: conditionField, - operator: conditionOperator, - value: parsedValue, - }; - } else if (type === 'EXIT') { - config = {reason: exitReason}; - } else if (type === 'WEBHOOK') { - if (!webhookUrl) { - toast.error('Webhook URL is required'); - setIsSubmitting(false); - return; - } - - let headers = {}; - if (webhookHeaders.trim()) { - try { - headers = JSON.parse(webhookHeaders); - } catch { - toast.error('Invalid JSON in webhook headers'); - setIsSubmitting(false); - return; - } - } - - config = { - url: webhookUrl, - method: webhookMethod, - headers, - }; - } else if (type === 'UPDATE_CONTACT') { - if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) { - toast.error('At least one field to update is required'); - setIsSubmitting(false); - return; - } - - config = {updates: contactUpdateData}; - } else if (type === 'WAIT_FOR_EVENT') { - if (!eventName) { - toast.error('Event name is required'); - 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: timeoutSeconds, - }; - } - - await network.fetch('POST', `/workflows/${workflowId}/steps`, { - type, - name, - - position: {x: 100, y: 100}, - config: config as any, - templateId: type === 'SEND_EMAIL' ? templateId : undefined, - }); - - toast.success('Step added successfully'); - - // Reset all fields - setName(''); - setType('SEND_EMAIL'); - setTemplateId(''); - setRecipientType('CONTACT'); - setCustomEmail(''); - setDelayAmount('24'); - setDelayUnit('hours'); - setConditionField(''); - setConditionOperator('equals'); - setConditionValue(''); - setAvailableFields([]); - setEventName(''); - setEventTimeoutAmount('1'); - setEventTimeoutUnit('days'); - setWebhookUrl(''); - setWebhookMethod('POST'); - setWebhookHeaders(''); - setContactUpdateData(null); - setExitReason('completed'); - - onOpenChange(false); - onSuccess(); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to add step'); - } finally { - setIsSubmitting(false); - } - }; - - return ( - - - - Add Step - {STEP_TYPE_DESCRIPTIONS[type] && ( -

{STEP_TYPE_DESCRIPTIONS[type]}

- )} -
-
-
-
- - -
- -
- - setName(e.target.value)} - required - placeholder="e.g., Send Welcome Email" - className="mt-1.5" - /> -
-
- - {/* SEND_EMAIL Configuration */} - {type === 'SEND_EMAIL' && ( -
-
- - -
- -
- - -
- - {recipientType === 'CUSTOM' && ( -
- - setCustomEmail(e.target.value)} - required - placeholder="e.g., admin@example.com" - className="mt-1.5" - /> -
- )} -
- )} - - {/* DELAY Configuration */} - {type === 'DELAY' && ( -
-
- - setDelayAmount(e.target.value)} - required - min="1" - max={ - delayUnit === 'minutes' - ? 525600 - : delayUnit === 'hours' - ? 8760 - : delayUnit === 'days' - ? 365 - : undefined - } - className="mt-1.5" - /> -
-
- - -
-
- )} - - {/* CONDITION Configuration */} - {type === 'CONDITION' && ( -
-
- - {loadingFields ? ( -
- - Loading fields... -
- ) : availableFields.length > 0 ? ( - <> - - - ) : ( - setConditionField(e.target.value)} - required - placeholder="e.g., contact.subscribed or contact.data.plan" - className="mt-1.5" - /> - )} -
- -
- - -
- - {needsValue && ( -
- - {currentFieldType === 'boolean' ? ( - - ) : currentFieldType === 'number' ? ( - setConditionValue(e.target.value)} - required - placeholder="e.g., 100" - className="mt-1.5" - /> - ) : currentFieldType === 'date' ? ( - setConditionValue(e.target.value)} - required - className="mt-1.5" - /> - ) : ( - setConditionValue(e.target.value)} - required - placeholder="e.g., premium, active" - className="mt-1.5" - /> - )} -
- )} -
- )} - - {/* WAIT_FOR_EVENT Configuration */} - {type === 'WAIT_FOR_EVENT' && ( -
-
- -
- { - setEventName(e.target.value); - setEventPopoverOpen(true); - }} - onFocus={() => setEventPopoverOpen(true)} - onBlur={() => { - setTimeout(() => setEventPopoverOpen(false), 150); - }} - required - placeholder="e.g., email.clicked, user.upgraded" - className="mt-1.5" - autoComplete="off" - /> - {eventPopoverOpen && ((eventNamesData?.eventNames?.length ?? 0) > 0 || eventName?.trim()) && ( -
- - - - {eventNamesData?.eventNames - ?.filter(n => !eventName || n.toLowerCase().includes(eventName.toLowerCase())) - .map(n => ( - { setEventName(n); setEventPopoverOpen(false); }}> - {n} - - ))} - {eventName?.trim() && !eventNamesData?.eventNames?.some(n => n === eventName.trim()) && ( - { setEventName(eventName.trim()); setEventPopoverOpen(false); }} - > - Use “{eventName.trim()}” - - )} - - - -
- )} -
-
- -
- -
- setEventTimeoutAmount(e.target.value)} - placeholder="1" - min="0" - max={ - eventTimeoutUnit === 'minutes' - ? 525600 - : eventTimeoutUnit === 'hours' - ? 8760 - : eventTimeoutUnit === 'days' - ? 365 - : undefined - } - className="flex-1" - /> - -
-

- If not received, the workflow continues after this time -

-
-
- )} - - {/* WEBHOOK Configuration */} - {type === 'WEBHOOK' && ( -
-
- - setWebhookUrl(e.target.value)} - required - placeholder="https://api.example.com/webhook" - className="font-mono mt-1.5" - /> -
- -
- - -
- -
- -