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., [email protected]"
|
||||||
|
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": "[email protected]", "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user