refactor: implement step dialog components for workflow editing

This commit is contained in:
Dries Augustyns
2026-05-04 20:25:19 +02:00
parent d1f357c300
commit ed9027b4ef
10 changed files with 1456 additions and 1952 deletions
@@ -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<string, unknown>): 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<ConditionMode>(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<BranchInput[]>(() => 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<BranchInput>) => {
setConditionBranches(prev => prev.map(b => (b.id === id ? {...b, ...patch} : b)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let newConfig: Record<string, unknown>;
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 (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-6">
<div className="space-y-4">
<ConditionModeToggle
mode={conditionMode}
onChange={handleModeChange}
blocksMultiToBinary={blocksMultiToBinary}
blocksBinaryToMulti={blocksBinaryToMulti}
showWiringWarning={showWiringWarning}
/>
<ConditionFieldPicker
value={conditionField}
onChange={handleConditionFieldChange}
availableFields={availableFields}
loading={loadingFields}
/>
{conditionMode === 'binary' && (
<BinaryCondition
operator={conditionOperator}
value={conditionValue}
onOperatorChange={setConditionOperator}
onValueChange={setConditionValue}
validOperators={validOperators}
fieldType={currentFieldType}
needsValue={needsValue}
/>
)}
{conditionMode === 'multi' && (
<MultiBranchEditor
branches={conditionBranches}
validOperators={validOperators}
onUpdateBranch={updateBranch}
onRemoveBranch={id => setConditionBranches(prev => prev.filter(b => b.id !== id))}
onAddBranch={() =>
setConditionBranches(prev => [
...prev,
{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''},
])
}
/>
)}
</div>
</div>
</StepDialogShell>
);
}
interface ConditionModeToggleProps {
mode: ConditionMode;
onChange: (mode: ConditionMode) => void;
blocksMultiToBinary: boolean;
blocksBinaryToMulti: boolean;
showWiringWarning: boolean;
}
function ConditionModeToggle({
mode,
onChange,
blocksMultiToBinary,
blocksBinaryToMulti,
showWiringWarning,
}: ConditionModeToggleProps) {
return (
<div>
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onChange('binary')}
disabled={mode === 'multi' && blocksMultiToBinary}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
mode === 'binary'
? 'border-neutral-900 bg-neutral-900 text-white'
: mode === 'multi' && blocksMultiToBinary
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
}`}
>
Simple (If/Else)
</button>
<button
type="button"
onClick={() => onChange('multi')}
disabled={mode === 'binary' && blocksBinaryToMulti}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
mode === 'multi'
? 'border-neutral-900 bg-neutral-900 text-white'
: mode === 'binary' && blocksBinaryToMulti
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
}`}
>
Multi-branch (Switch)
</button>
</div>
<p className="text-xs text-neutral-500 mt-1.5">
{mode === 'binary'
? 'Evaluates a single condition with Yes/No paths'
: 'Match a field against multiple values, each routing to its own branch'}
</p>
{blocksMultiToBinary && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
<AlertTriangle className="h-3 w-3 inline mr-1" />
Cannot switch to simple mode: branches have connected nodes. Disconnect all branch connections first.
</div>
)}
{blocksBinaryToMulti && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
<AlertTriangle className="h-3 w-3 inline mr-1" />
Cannot switch to multi-branch mode: Yes/No branches have connected nodes. Disconnect all branch connections
first.
</div>
)}
{showWiringWarning && (
<div className="mt-2 p-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800">
<AlertTriangle className="h-3 w-3 inline mr-1" />
Changing mode will disconnect existing branches. You will need to rewire them.
</div>
)}
</div>
);
}
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<Record<string, AvailableField[]>>((acc, field) => {
if (!acc[field.category]) acc[field.category] = [];
acc[field.category]!.push(field);
return acc;
}, {});
}, [availableFields]);
return (
<div>
<Label htmlFor="editConditionField">Field to Check *</Label>
{loading ? (
<div className="flex items-center gap-2 px-3 py-2 border border-neutral-200 rounded-lg text-sm text-neutral-500 mt-1.5">
<IconSpinner size="sm" />
Loading fields...
</div>
) : availableFields.length > 0 ? (
<Select value={value} onValueChange={onChange} required>
<SelectTrigger id="editConditionField" className="mt-1.5">
<SelectValue placeholder="Select a field..." />
</SelectTrigger>
<SelectContent>
{Object.entries(grouped).map(([category, fields]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">{category}</div>
{fields.map(field => (
<SelectItem key={field.field} value={field.field}>
<div className="flex items-center gap-2">
<span>{field.field.replace('contact.', '').replace('data.', '')}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-200 text-neutral-600 font-mono">
{field.type}
</span>
</div>
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
) : (
<Input
id="editConditionField"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
required
placeholder="e.g., contact.subscribed or contact.data.plan"
className="mt-1.5"
/>
)}
</div>
);
}
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 (
<>
<div>
<Label htmlFor="editConditionOperator">Operator</Label>
<Select value={operator} onValueChange={onOperatorChange}>
<SelectTrigger id="editConditionOperator" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{validOperators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{needsValue && (
<div>
<Label htmlFor="editConditionValue">Value</Label>
<ConditionValueInput fieldType={fieldType} value={value} onChange={onValueChange} />
</div>
)}
</>
);
}
interface ConditionValueInputProps {
fieldType: string;
value: string;
onChange: (value: string) => void;
}
function ConditionValueInput({fieldType, value, onChange}: ConditionValueInputProps) {
if (fieldType === 'boolean') {
return (
<Select value={value || 'true'} onValueChange={onChange}>
<SelectTrigger id="editConditionValue" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
);
}
const inputType = fieldType === 'number' ? 'number' : fieldType === 'date' ? 'datetime-local' : 'text';
const placeholder =
fieldType === 'number' ? 'e.g., 100' : fieldType === 'date' ? '' : 'e.g., premium, active';
return (
<Input
id="editConditionValue"
type={inputType}
value={value}
onChange={e => onChange(e.target.value)}
required
placeholder={placeholder}
className="mt-1.5"
/>
);
}
interface MultiBranchEditorProps {
branches: BranchInput[];
validOperators: OperatorOption[];
onUpdateBranch: (id: string, patch: Partial<BranchInput>) => void;
onRemoveBranch: (id: string) => void;
onAddBranch: () => void;
}
function MultiBranchEditor({branches, validOperators, onUpdateBranch, onRemoveBranch, onAddBranch}: MultiBranchEditorProps) {
return (
<div className="space-y-3">
<Label>Branches</Label>
<p className="text-xs text-neutral-500">
Each branch defines a condition. The first matching branch is taken. Contacts not matching any branch follow the
Default path.
</p>
{branches.map((branch, idx) => (
<div key={branch.id} className="p-3 border border-neutral-200 rounded-lg space-y-3 bg-neutral-50/50">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-neutral-500">Branch {idx + 1}</span>
{branches.length > 1 && (
<Button
type="button"
variant="destructiveGhost"
size="icon"
className="h-7 w-7"
onClick={() => onRemoveBranch(branch.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div>
<Label className="text-xs">Name *</Label>
<Input
type="text"
value={branch.name}
onChange={e => onUpdateBranch(branch.id, {name: e.target.value})}
placeholder="e.g., Premium, Free, Enterprise"
className="mt-1 h-8 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">Operator</Label>
<Select
value={branch.operator}
onValueChange={val => onUpdateBranch(branch.id, {operator: val})}
>
<SelectTrigger className="mt-1 h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{validOperators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!NO_VALUE_OPERATORS.includes(branch.operator) && (
<div>
<Label className="text-xs">Value</Label>
<Input
type="text"
value={branch.value}
onChange={e => onUpdateBranch(branch.id, {value: e.target.value})}
placeholder="Value..."
className="mt-1 h-8 text-sm"
/>
</div>
)}
</div>
</div>
))}
{branches.length < 20 && (
<button
type="button"
onClick={onAddBranch}
className="flex items-center gap-1.5 text-sm text-neutral-700 hover:text-neutral-900 font-medium"
>
<Plus className="h-3.5 w-3.5" />
Add Branch
</button>
)}
<div className="p-2 bg-neutral-100 rounded-lg text-xs text-neutral-600 flex items-start gap-2">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<span>
Branches are evaluated in order. The first match wins. Contacts not matching any branch will follow the{' '}
<strong>Default</strong> path.
</span>
</div>
</div>
);
}
@@ -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<DelayUnit, number> = {
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<DelayUnit>(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 (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="editDelayAmount">Amount</Label>
<Input
id="editDelayAmount"
type="number"
value={delayAmount}
onChange={e => setDelayAmount(e.target.value)}
required
min="1"
max={MAX_DELAY_BY_UNIT[delayUnit]}
className="mt-1.5"
/>
</div>
<div>
<Label htmlFor="editDelayUnit">Unit</Label>
<Select value={delayUnit} onValueChange={value => setDelayUnit(value as DelayUnit)}>
<SelectTrigger id="editDelayUnit" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</StepDialogShell>
);
}
@@ -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 (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<Label htmlFor="editExitReason">Exit Reason (optional)</Label>
<Select value={exitReason} onValueChange={setExitReason}>
<SelectTrigger id="editExitReason" className="mt-1.5">
<SelectValue placeholder="Select exit reason..." />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription value="completed" title="Completed" description="Contact finished the workflow successfully" />
<SelectItemWithDescription value="unsubscribed" title="Unsubscribed" description="Contact unsubscribed from communications" />
<SelectItemWithDescription value="not_eligible" title="Not Eligible" description="Contact doesn't meet the required criteria" />
<SelectItemWithDescription value="opted_out" title="Opted Out" description="Contact opted out of this workflow" />
<SelectItemWithDescription value="goal_achieved" title="Goal Achieved" description="Workflow goal was met before completion" />
<SelectItemWithDescription value="duplicate" title="Duplicate" description="Contact was already in this workflow" />
<SelectItemWithDescription value="error" title="Error" description="A technical issue occurred" />
<SelectItemWithDescription value="other" title="Other" description="Custom or unlisted reason" />
</SelectContent>
</Select>
</div>
</StepDialogShell>
);
}
@@ -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 (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-4">
<div>
<Label htmlFor="editTemplate">Email Template</Label>
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
</div>
<div>
<Label htmlFor="editRecipientType">Send To</Label>
<Select value={recipientType} onValueChange={value => setRecipientType(value as 'CONTACT' | 'CUSTOM')}>
<SelectTrigger id="editRecipientType" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value="CONTACT"
title="Contact"
description="Send to the contact that triggered the workflow"
/>
<SelectItemWithDescription
value="CUSTOM"
title="Custom Email"
description="Send to a specific email address"
/>
</SelectContent>
</Select>
</div>
{recipientType === 'CUSTOM' && (
<div>
<Label htmlFor="editCustomEmail">Email Address</Label>
<Input
id="editCustomEmail"
type="email"
value={customEmail}
onChange={e => setCustomEmail(e.target.value)}
required
placeholder="e.g., admin@example.com"
className="mt-1.5"
/>
</div>
)}
</div>
</StepDialogShell>
);
}
@@ -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<string, string | number | boolean>)
: null;
const [name, setName] = useState(step.name);
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | 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 (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
</StepDialogShell>
);
}
@@ -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<TimeUnit, number> = {
minutes: 60,
hours: 60 * 60,
days: 60 * 60 * 24,
};
const MAX_BY_UNIT: Record<TimeUnit, number> = {
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<TimeUnit>(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 (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-4">
<div>
<Label htmlFor="editEventName">Event Name</Label>
<div className="relative">
<Input
id="editEventName"
type="text"
value={eventName}
onChange={e => {
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()) && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md">
<Command>
<CommandList>
<CommandGroup>
{filteredEventNames?.map(n => (
<CommandItem
key={n}
value={n}
onSelect={() => {
setEventName(n);
setEventPopoverOpen(false);
}}
>
{n}
</CommandItem>
))}
{showCustomEntry && (
<CommandItem
key="__custom__"
value={eventName.trim()}
onSelect={() => {
setEventName(eventName.trim());
setEventPopoverOpen(false);
}}
>
Use &ldquo;{eventName.trim()}&rdquo;
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</div>
<div>
<Label htmlFor="editEventTimeoutAmount">Timeout (optional)</Label>
<div className="flex gap-2 mt-1.5">
<Input
id="editEventTimeoutAmount"
type="number"
value={timeoutAmount}
onChange={e => setTimeoutAmount(e.target.value)}
placeholder="1"
min="0"
max={MAX_BY_UNIT[timeoutUnit]}
className="flex-1"
/>
<Select value={timeoutUnit} onValueChange={value => setTimeoutUnit(value as TimeUnit)}>
<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>
<p className="text-xs text-neutral-500 mt-1.5">If not received, the workflow continues after this time</p>
</div>
</div>
</StepDialogShell>
);
}
@@ -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<string, unknown>).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<HeaderEntry[]>(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<string, string> = {};
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<HeaderEntry>) => {
setWebhookHeaders(prev => prev.map((h, i) => (i === index ? {...h, ...patch} : h)));
};
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-4">
<Collapsible open={showWebhookInfo} onOpenChange={setShowWebhookInfo}>
<div className="flex items-center justify-between">
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700">
<ChevronDown className={`h-3 w-3 transition-transform ${showWebhookInfo ? 'rotate-180' : ''}`} />
{showWebhookInfo ? 'Hide' : 'View'} request payload
</CollapsibleTrigger>
<Link
href={`${WIKI_URI}/guides/webhooks#webhook-payload`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-neutral-500 hover:text-neutral-700 underline underline-offset-2"
>
Webhook guide
</Link>
</div>
<CollapsibleContent className="mt-2">
<pre className="text-[10px] bg-neutral-50 p-2 rounded border border-neutral-200 overflow-x-auto">
{`{
"contact": { "email": "user@example.com", "subscribed": true, "data": { ... } },
"workflow": { "id": "wf_...", "name": "Welcome Series" },
"execution": { "id": "exec_...", "startedAt": "2025-01-19T..." },
"event": { ... }
}`}
</pre>
</CollapsibleContent>
</Collapsible>
<div>
<Label htmlFor="editWebhookUrl">URL</Label>
<Input
className="font-mono mt-1.5"
id="editWebhookUrl"
type="url"
value={webhookUrl}
onChange={e => setWebhookUrl(e.target.value)}
required
placeholder="https://api.example.com/webhook"
/>
</div>
<div>
<Label htmlFor="editWebhookMethod">Method</Label>
<Select value={webhookMethod} onValueChange={setWebhookMethod}>
<SelectTrigger id="editWebhookMethod" className="font-mono mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label>Headers</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setWebhookHeaders(prev => [...prev, {key: '', value: ''}])}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
Add
</Button>
</div>
<div className="space-y-2">
{webhookHeaders.map((header, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="Name"
value={header.key}
onChange={e => updateHeader(index, {key: e.target.value})}
className="text-sm font-mono"
/>
<Input
placeholder="Value"
value={header.value}
onChange={e => updateHeader(index, {value: e.target.value})}
className="text-sm font-mono"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setWebhookHeaders(prev => prev.filter((_, i) => i !== index))}
className="h-9 w-9 p-0 flex-shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</div>
</StepDialogShell>
);
}
@@ -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 <SendEmailStepDialog {...props} />;
case 'DELAY':
return <DelayStepDialog {...props} />;
case 'CONDITION':
return <ConditionStepDialog {...props} />;
case 'WAIT_FOR_EVENT':
return <WaitForEventStepDialog {...props} />;
case 'WEBHOOK':
return <WebhookStepDialog {...props} />;
case 'UPDATE_CONTACT':
return <UpdateContactStepDialog {...props} />;
case 'EXIT':
return <ExitStepDialog {...props} />;
default:
return null;
}
}
@@ -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<WorkflowStep['type'], string> = {
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<WorkflowStep['type'], string> = {
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<string, unknown>;
templateId?: string;
}
export function useStepUpdate(workflowId: string, stepId: string) {
const [isSubmitting, setIsSubmitting] = useState(false);
const update = async (input: UpdateStepInput): Promise<boolean> => {
setIsSubmitting(true);
try {
await network.fetch<WorkflowStep, typeof WorkflowSchemas.updateStep>(
'PATCH',
`/workflows/${workflowId}/steps/${stepId}`,
input as Parameters<typeof network.fetch>[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<string, unknown> {
return step.config && typeof step.config === 'object' && !Array.isArray(step.config)
? (step.config as Record<string, unknown>)
: {};
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit {STEP_TYPE_LABELS[step.type] ?? step.type}</DialogTitle>
{STEP_TYPE_DESCRIPTIONS[step.type] && (
<p className="text-sm text-neutral-500 mt-1">{STEP_TYPE_DESCRIPTIONS[step.type]}</p>
)}
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-5">
<div>
<Label htmlFor="editStepName">Step Name</Label>
<Input
id="editStepName"
type="text"
value={name}
onChange={e => onNameChange(e.target.value)}
required
placeholder="e.g., Send Welcome Email"
className="mt-1.5"
/>
</div>
{children}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}