refactor: implement step dialog components for workflow editing
This commit is contained in:
@@ -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 “{eventName.trim()}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user