Add better segmenting

This commit is contained in:
Dries Augustyns
2025-12-02 09:03:05 +01:00
parent b521a405bb
commit fe2e038037
23 changed files with 2285 additions and 855 deletions
@@ -0,0 +1,691 @@
import {Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
import type {FilterCondition, FilterGroup, SegmentFilter, SegmentFilterOperator} from '@plunk/types';
import {Plus, Trash2, GripVertical, Check, ChevronsUpDown, Search} from 'lucide-react';
import {useState, useEffect} from 'react';
import {network} from '../lib/network';
const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
];
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'triggered', label: 'Ever occurred'},
{value: 'triggeredWithin', label: 'Occurred within'},
{value: 'notTriggered', label: 'Never occurred'},
];
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const STANDARD_FIELDS = [
// Contact fields
{value: 'email', label: 'Email', type: 'string', category: 'Contact Fields'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean', category: 'Contact Fields'},
{value: 'createdAt', label: 'Created At', type: 'date', category: 'Contact Fields'},
{value: 'updatedAt', label: 'Updated At', type: 'date', category: 'Contact Fields'},
] as const;
interface FieldOption {
value: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email';
category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity';
}
// Hook to fetch available fields and events
function useAvailableOptions() {
const [fields, setFields] = useState<FieldOption[]>([...STANDARD_FIELDS]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchOptions = async () => {
try {
// Fetch contact fields with types
const fieldsData = await network.fetch<{
fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>;
}>('GET', '/contacts/fields');
// Fetch event names
const eventsData = await network.fetch<{eventNames: string[]}>('GET', '/events/names');
// Build field options from typed fields
const typedFields: FieldOption[] = (fieldsData.fields || []).map(f => {
const isCustomData = f.field.startsWith('data.');
return {
value: f.field,
label: isCustomData ? f.field.replace('data.', '') : f.field,
type: f.type,
category: isCustomData ? ('Custom Data' as const) : ('Contact Fields' as const),
};
});
// Build event options
const eventOptions: FieldOption[] = [];
const emailOptions: FieldOption[] = [];
(eventsData.eventNames || []).forEach((name: string) => {
if (name.startsWith('email.')) {
emailOptions.push({
value: name,
label: name.replace('email.', '').replace(/([A-Z])/g, ' $1').trim(),
type: 'event' as const,
category: 'Email Activity' as const,
});
} else {
eventOptions.push({
value: name,
label: name,
type: 'event' as const,
category: 'Events' as const,
});
}
});
setFields([...typedFields, ...eventOptions, ...emailOptions]);
} catch (error) {
console.error('Failed to fetch available fields and events:', error);
} finally {
setLoading(false);
}
};
fetchOptions();
}, []);
return {fields, loading};
}
interface FilterRowProps {
filter: SegmentFilter;
onChange: (filter: SegmentFilter) => void;
onRemove: () => void;
availableFields: FieldOption[];
}
function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator);
const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator);
// Get field type from available fields
const fieldOption = availableFields.find(f => f.value === filter.field);
const fieldType = fieldOption?.type || 'string';
const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email';
// Get operators based on field type
const getOperators = () => {
if (isEventOrEmailActivity) {
return EVENT_OPERATORS;
}
// Filter operators based on field type
if (fieldType === 'boolean') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
);
}
if (fieldType === 'number' || fieldType === 'date') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
);
}
// String type - no within operator, no comparison operators
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
);
};
const handleFieldChange = (value: string) => {
const selectedField = availableFields.find(f => f.value === value);
const newFieldType = selectedField?.type || 'string';
const isEvent = newFieldType === 'event' || newFieldType === 'email';
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
// Determine default operator and value based on new field type
let newOperator = filter.operator;
let newValue: any = undefined;
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
if (isEvent && !currentOperatorIsEvent) {
// Switching to event field
newOperator = 'triggered';
newValue = undefined;
newUnit = undefined;
} else if (!isEvent && currentOperatorIsEvent) {
// Switching from event to non-event field
newOperator = 'equals';
newValue = getDefaultValueForType(newFieldType);
newUnit = undefined;
} else if (fieldType !== newFieldType) {
// Field type changed (e.g., date to boolean, number to string)
// Check if current operator is valid for new type
const validOperators = getOperatorsForType(newFieldType, isEvent);
const isOperatorValid = validOperators.some(op => op.value === filter.operator);
if (!isOperatorValid) {
newOperator = 'equals';
}
// Reset value to appropriate default for new type
newValue = getDefaultValueForType(newFieldType);
// Always clear unit when changing field types, even if operator is still valid
// This handles cases like switching from date "within" to string field
newUnit = undefined;
// If the new operator doesn't support units but we had them, ensure value is appropriate
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
if (!newOperatorNeedsUnit) {
// Convert numeric value back to appropriate type for the field
newValue = getDefaultValueForType(newFieldType);
}
}
onChange({
field: value,
operator: newOperator,
value: newValue,
unit: newUnit,
});
setOpen(false);
setSearch('');
};
// Helper to get default value based on field type
const getDefaultValueForType = (type: string) => {
switch (type) {
case 'boolean':
return true;
case 'number':
return 0;
case 'date':
return '';
default:
return '';
}
};
// Helper to get valid operators for a field type
const getOperatorsForType = (type: string, isEvent: boolean) => {
if (isEvent) {
return EVENT_OPERATORS;
}
if (type === 'boolean') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'exists', 'notExists'].includes(op.value)
);
}
if (type === 'number' || type === 'date') {
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'exists', 'notExists', 'within'].includes(op.value)
);
}
// String type - no within operator, no comparison operators
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value)
);
};
// Get label for selected field
const getFieldLabel = () => {
const field = availableFields.find(f => f.value === filter.field);
return field?.label || filter.field;
};
// Group fields by category for display
const groupedFields = availableFields.reduce<Record<string, FieldOption[]>>((acc, field) => {
if (!acc[field.category]) {
acc[field.category] = [];
}
acc[field.category]!.push(field);
return acc;
}, {});
// Filter fields based on search
const filteredGroups = Object.entries(groupedFields).reduce((acc, [category, fields]) => {
const filtered = fields.filter(f =>
f.label.toLowerCase().includes(search.toLowerCase()) ||
f.value.toLowerCase().includes(search.toLowerCase())
);
if (filtered.length > 0) {
acc[category] = filtered;
}
return acc;
}, {} as Record<string, FieldOption[]>);
return (
<div className="flex items-start gap-2 p-3 bg-neutral-50 rounded-lg border border-neutral-200">
<div className="flex-1 grid grid-cols-3 gap-3">
{/* Field Selection */}
<div className="space-y-1.5">
<Label className="text-xs text-neutral-600">Field</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between text-sm font-normal"
>
<span className="truncate">{getFieldLabel()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0" align="start">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="Search fields, events, or email activity..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<div className="max-h-[300px] overflow-y-auto p-1">
{Object.keys(filteredGroups).length === 0 ? (
<div className="py-6 text-center text-sm text-neutral-500">
No fields or events found.
</div>
) : (
Object.entries(filteredGroups).map(([category, fields]) => (
<div key={category} className="py-1">
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">
{category}
</div>
{fields.map(field => (
<button
key={field.value}
onClick={() => handleFieldChange(field.value)}
className="w-full flex items-center rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-100 cursor-pointer text-left"
>
<Check
className={`mr-2 h-4 w-4 ${
filter.field === field.value ? 'opacity-100' : 'opacity-0'
}`}
/>
<div className="flex flex-col flex-1">
<div className="flex items-center gap-2">
<span className="text-neutral-900">{field.label}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 font-mono">
{field.type}
</span>
</div>
{field.value !== field.label && (
<span className="text-xs text-neutral-500">{field.value}</span>
)}
</div>
</button>
))}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
</div>
{/* Operator Selection */}
<div className="space-y-1.5">
<Label className="text-xs text-neutral-600">Operator</Label>
<Select
value={filter.operator}
onValueChange={(v: SegmentFilterOperator) => {
const newOperator = v;
const oldOperator = filter.operator;
// Check if we're switching between operators that need different value types
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
const oldNeedsUnit = ['within', 'triggeredWithin'].includes(oldOperator);
const newNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
let updatedFilter: SegmentFilter = {...filter, operator: newOperator};
// Clear value if new operator doesn't need one
if (!newNeedsValue && oldNeedsValue) {
updatedFilter.value = undefined;
}
// Clear or set unit appropriately
if (!newNeedsUnit && oldNeedsUnit) {
updatedFilter.unit = undefined;
} else if (newNeedsUnit && !oldNeedsUnit) {
updatedFilter.unit = 'days';
// Set default numeric value if needed
if (typeof updatedFilter.value !== 'number') {
updatedFilter.value = 7;
}
}
// If switching to an operator that needs a value but we don't have one, set default
if (newNeedsValue && !oldNeedsValue) {
updatedFilter.value = getDefaultValueForType(fieldType);
}
onChange(updatedFilter);
}}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getOperators().map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value Input */}
<div className="space-y-1.5">
<Label className="text-xs text-neutral-600">Value</Label>
{!needsValue ? (
<div className="h-9 flex items-center text-sm text-neutral-400 px-3 bg-neutral-100 rounded border border-neutral-200">
No value needed
</div>
) : needsUnit ? (
<div className="flex gap-1">
<Input
type="number"
value={filter.value as number}
onChange={e => onChange({...filter, value: parseInt(e.target.value) || 0})}
className="text-sm flex-1"
min="1"
/>
<Select value={filter.unit || 'days'} onValueChange={(v: 'days' | 'hours' | 'minutes') => onChange({...filter, unit: v})}>
<SelectTrigger className="text-sm w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : fieldType === 'boolean' ? (
<Select value={String(filter.value ?? 'true')} onValueChange={v => onChange({...filter, value: v === 'true'})}>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : fieldType === 'number' ? (
<Input
type="number"
value={typeof filter.value === 'number' ? filter.value : ''}
onChange={e => {
const val = e.target.value;
onChange({...filter, value: val === '' ? 0 : parseFloat(val) || 0});
}}
className="text-sm"
placeholder="Enter number"
/>
) : fieldType === 'date' ? (
<Input
type="date"
value={filter.value ? String(filter.value).split('T')[0] : ''}
onChange={e => onChange({...filter, value: e.target.value})}
className="text-sm"
/>
) : (
<Input
type="text"
value={String(filter.value ?? '')}
onChange={e => onChange({...filter, value: e.target.value})}
className="text-sm"
placeholder="Enter value"
/>
)}
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
interface FilterGroupComponentProps {
group: FilterGroup;
onChange: (group: FilterGroup) => void;
onRemove?: () => void;
depth?: number;
availableFields: FieldOption[];
}
function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFields}: FilterGroupComponentProps) {
const addFilter = () => {
onChange({
...group,
filters: [...group.filters, {field: 'email', operator: 'contains', value: ''}],
});
};
const updateFilter = (index: number, filter: SegmentFilter) => {
onChange({
...group,
filters: group.filters.map((f, i) => (i === index ? filter : f)),
});
};
const removeFilter = (index: number) => {
onChange({
...group,
filters: group.filters.filter((_, i) => i !== index),
});
};
const addNestedCondition = () => {
onChange({
...group,
conditions: {
logic: 'AND',
groups: [{filters: [{field: 'email', operator: 'contains', value: ''}]}],
},
});
};
const updateNestedCondition = (condition: FilterCondition) => {
onChange({
...group,
conditions: condition,
});
};
const removeNestedCondition = () => {
const {conditions, ...rest} = group;
onChange(rest);
};
const bgColors = ['bg-white', 'bg-blue-50/50', 'bg-purple-50/50', 'bg-green-50/50'];
const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300'];
return (
<div className={`p-4 rounded-lg border-2 ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700">Filter Group {depth > 0 && `(Nested)`}</span>
</div>
{onRemove && (
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="space-y-3">
{group.filters.map((filter, index) => (
<FilterRow key={index} filter={filter} onChange={f => updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} />
))}
{group.conditions && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-neutral-600 uppercase tracking-wide">Nested Conditions</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={removeNestedCondition}
className="h-6 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
>
Remove nested
</Button>
</div>
<FilterConditionComponent condition={group.conditions} onChange={updateNestedCondition} depth={depth + 1} availableFields={availableFields} />
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" size="sm" onClick={addFilter} className="flex-1">
<Plus className="h-3 w-3 mr-1" />
Add Filter
</Button>
{!group.conditions && (
<Button type="button" variant="outline" size="sm" onClick={addNestedCondition} className="flex-1">
<Plus className="h-3 w-3 mr-1" />
Add Nested Condition
</Button>
)}
</div>
</div>
</div>
);
}
interface FilterConditionComponentProps {
condition: FilterCondition;
onChange: (condition: FilterCondition) => void;
depth?: number;
availableFields: FieldOption[];
}
function FilterConditionComponent({condition, onChange, depth = 0, availableFields}: FilterConditionComponentProps) {
const addGroup = () => {
onChange({
...condition,
groups: [...condition.groups, {filters: [{field: 'email', operator: 'contains', value: ''}]}],
});
};
const updateGroup = (index: number, group: FilterGroup) => {
onChange({
...condition,
groups: condition.groups.map((g, i) => (i === index ? group : g)),
});
};
const removeGroup = (index: number) => {
onChange({
...condition,
groups: condition.groups.filter((_, i) => i !== index),
});
};
const toggleLogic = () => {
onChange({
...condition,
logic: condition.logic === 'AND' ? 'OR' : 'AND',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-600">Groups are combined with:</span>
<Button
type="button"
variant={condition.logic === 'AND' ? 'default' : 'outline'}
size="sm"
onClick={toggleLogic}
className="font-mono font-bold"
>
{condition.logic}
</Button>
</div>
</div>
<div className="space-y-4">
{condition.groups.map((group, index) => (
<div key={index}>
{index > 0 && (
<div className="flex items-center justify-center my-2">
<div className="px-3 py-1 bg-neutral-900 text-white text-xs font-bold font-mono rounded-full">{condition.logic}</div>
</div>
)}
<FilterGroupComponent
group={group}
onChange={g => updateGroup(index, g)}
onRemove={condition.groups.length > 1 ? () => removeGroup(index) : undefined}
depth={depth}
availableFields={availableFields}
/>
</div>
))}
</div>
<Button type="button" variant="outline" onClick={addGroup} className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Group
</Button>
</div>
);
}
interface SegmentFilterBuilderProps {
condition: FilterCondition;
onChange: (condition: FilterCondition) => void;
}
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-neutral-900">Filter Conditions</h3>
<p className="text-sm text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
</div>
</div>
{loading ? (
<div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>
) : (
<FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />
)}
</div>
);
}
+77 -11
View File
@@ -261,7 +261,12 @@ function CustomNode({
<div className="text-xs text-neutral-600">
<div className="flex items-center gap-1 mb-1">
<span className="font-medium">🔀</span>
<span className="font-mono text-[10px] truncate">{data.config.field}</span>
<span className="font-mono text-[10px] truncate">
{/* Handle both legacy format {field, type} and new format (string) */}
{typeof data.config.field === 'object' && data.config.field !== null && 'field' in data.config.field
? String(data.config.field.field)
: String(data.config.field)}
</span>
</div>
<div className="text-[10px] text-neutral-500 ml-4">
{data.config.operator} &quot;{String(data.config.value)}&quot;
@@ -582,6 +587,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
name: `New ${stepType.toLowerCase().replace('_', ' ')}`,
position: {x: 0, y: 0}, // Will be auto-positioned by dagre layout
config: {},
autoConnect: false, // We manually create transitions to preserve branch information
},
);
@@ -625,12 +631,45 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
window.dispatchEvent(event);
};
// Get all steps that will be affected by deleting a step (the step itself + all downstream steps)
const getAffectedSteps = useCallback(
(stepId: string): typeof steps => {
const affected = new Set<string>();
const queue = [stepId];
// BFS to find all downstream steps
while (queue.length > 0) {
const currentId = queue.shift()!;
if (affected.has(currentId)) continue;
affected.add(currentId);
const currentStep = steps.find(s => s.id === currentId);
if (currentStep?.outgoingTransitions) {
for (const transition of currentStep.outgoingTransitions) {
if (!affected.has(transition.toStepId)) {
queue.push(transition.toStepId);
}
}
}
}
return steps.filter(s => affected.has(s.id));
},
[steps],
);
const handleDeleteStep = async () => {
if (!stepToDelete) return;
try {
await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`);
toast.success('Step deleted');
const affectedSteps = getAffectedSteps(stepToDelete);
if (affectedSteps.length > 1) {
toast.success(`Deleted ${affectedSteps.length} steps`);
} else {
toast.success('Step deleted');
}
onUpdate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete step');
@@ -782,15 +821,42 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
</DialogContent>
</Dialog>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteStep}
title="Delete Step"
description="Are you sure you want to delete this step?"
confirmText="Delete"
variant="destructive"
/>
{stepToDelete && (() => {
const affectedSteps = getAffectedSteps(stepToDelete);
const stepToDeleteData = steps.find(s => s.id === stepToDelete);
const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
return (
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteStep}
title="Delete Step"
description={
downstreamSteps.length > 0 ? (
<div className="space-y-3">
<p>
Deleting &quot;{stepToDeleteData?.name}&quot; will also delete {downstreamSteps.length} downstream{' '}
{downstreamSteps.length === 1 ? 'step' : 'steps'}:
</p>
<ul className="list-disc list-inside text-sm text-neutral-600 max-h-32 overflow-y-auto bg-neutral-50 p-3 rounded border border-neutral-200">
{downstreamSteps.map(step => (
<li key={step.id}>
{step.name} ({step.type})
</li>
))}
</ul>
<p className="text-sm font-medium text-red-600">This action cannot be undone.</p>
</div>
) : (
`Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.`
)
}
confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'}
variant="destructive"
/>
);
})()}
</>
);
}
+38 -181
View File
@@ -35,38 +35,9 @@ import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import type {SegmentFilter} from '@plunk/types';
import type {FilterCondition} from '@plunk/types';
import {SegmentSchemas} from '@plunk/shared';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
interface PaginatedContacts {
contacts: Contact[];
@@ -76,6 +47,18 @@ interface PaginatedContacts {
totalPages: number;
}
// Count total filters in a condition (recursive)
function countFilters(condition: FilterCondition): number {
let count = 0;
for (const group of condition.groups) {
count += group.filters.length;
if (group.conditions) {
count += countFilters(group.conditions);
}
}
return count;
}
export default function SegmentDetailPage() {
const router = useRouter();
const {id} = router.query;
@@ -89,7 +72,10 @@ export default function SegmentDetailPage() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([]);
const [condition, setCondition] = useState<FilterCondition>({
logic: 'AND',
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isComputing, setIsComputing] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -100,22 +86,13 @@ export default function SegmentDetailPage() {
setName(segment.name);
setDescription(segment.description || '');
setTrackMembership(segment.trackMembership);
setFilters((segment.filters as unknown as SegmentFilter[]) || []);
setCondition((segment.condition as unknown as FilterCondition) || {
logic: 'AND',
groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}],
});
}
}, [segment]);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
@@ -124,7 +101,7 @@ export default function SegmentDetailPage() {
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
name,
description: description || undefined,
filters,
condition,
trackMembership,
});
toast.success('Segment updated successfully');
@@ -167,13 +144,6 @@ export default function SegmentDetailPage() {
}
};
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
if (isLoading) {
return (
@@ -297,139 +267,17 @@ export default function SegmentDetailPage() {
</CardContent>
</Card>
{/* Filters */}
{/* Filter Builder */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<CardContent className="pt-6">
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end">
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
<Save className="h-4 w-4" />
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
@@ -536,7 +384,16 @@ export default function SegmentDetailPage() {
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-lg font-semibold text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0}
{countFilters(segment.condition as unknown as FilterCondition)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Groups</span>
</div>
<span className="text-lg font-semibold text-neutral-900">
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
</span>
</div>
</CardContent>
+19 -1
View File
@@ -10,6 +10,7 @@ import {
ConfirmDialog,
} from '@plunk/ui';
import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
@@ -19,6 +20,23 @@ import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
// Helper function to count total filters in a condition
function countFiltersInCondition(condition: unknown): number {
if (!condition || typeof condition !== 'object') return 0;
const cond = condition as FilterCondition;
if (!cond.groups || !Array.isArray(cond.groups)) return 0;
return cond.groups.reduce((total, group) => {
let count = group.filters?.length || 0;
// Recursively count nested conditions
if (group.conditions) {
count += countFiltersInCondition(group.conditions);
}
return total + count;
}, 0);
}
export default function SegmentsPage() {
// Limit to 50 segments to avoid loading thousands into the browser
const {
@@ -147,7 +165,7 @@ export default function SegmentsPage() {
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0}
{countFiltersInCondition(segment.condition)}
</span>
</div>
+99 -289
View File
@@ -1,80 +1,32 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label} from '@plunk/ui';
import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
import {network} from '../../lib/network';
import {ArrowLeft, Filter, Plus, Save, Trash2} from 'lucide-react';
import {ArrowLeft, Save} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import type {SegmentFilter} from '@plunk/types';
import type {FilterCondition} from '@plunk/types';
import type {Segment} from '@plunk/db';
import {SegmentSchemas} from '@plunk/shared';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
export default function NewSegmentPage() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([{field: 'subscribed', operator: 'equals', value: true}]);
const [condition, setCondition] = useState<FilterCondition>({
logic: 'AND',
groups: [
{
filters: [{field: 'subscribed', operator: 'equals', value: true}],
},
],
});
const [isSubmitting, setIsSubmitting] = useState(false);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
@@ -83,7 +35,7 @@ export default function NewSegmentPage() {
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
name,
description: description || undefined,
filters,
condition,
trackMembership,
});
toast.success('Segment created successfully');
@@ -95,241 +47,99 @@ export default function NewSegmentPage() {
}
};
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
return (
<>
<NextSeo title="Create Segment" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
<p className="text-neutral-500 mt-1">Define filters to automatically group contacts</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Give your segment a name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., Active Pro Users"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts (all filters must match)</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.length === 0 ? (
<div className="text-center py-8">
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500 mb-4">No filters defined. Add at least one filter.</p>
<Button type="button" variant="outline" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add First Filter
</Button>
</div>
) : (
filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit (for within operator) */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Remove button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/segments">
<Button type="button" variant="outline" disabled={isSubmitting}>
Cancel
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Creating...' : 'Create Segment'}
</Button>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
<p className="text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
</div>
</div>
</form>
</div>
</DashboardLayout>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Give your segment a name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., VIP Customers or Recent High Spenders"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on VIP plan OR recent signups who spent $1000+"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filter Builder */}
<Card>
<CardContent className="pt-6">
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Link href="/segments">
<Button type="button" variant="outline" disabled={isSubmitting}>
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? 'Creating...' : 'Create Segment'}
</Button>
</div>
</form>
</div>
</DashboardLayout>
</>
);
}
+351 -119
View File
@@ -6,6 +6,7 @@ import {
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Dialog,
DialogContent,
DialogFooter,
@@ -55,10 +56,13 @@ interface PaginatedExecutions {
export default function WorkflowEditorPage() {
const router = useRouter();
const {id} = router.query;
const [activeTab, setActiveTab] = useState<'builder' | 'executions' | 'debug'>('builder');
const [activeTab, setActiveTab] = useState<'builder' | 'executions'>('builder');
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
const [showTestDialog, setShowTestDialog] = useState(false);
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
const [showCancelAllDialog, setShowCancelAllDialog] = useState(false);
const [executionToCancel, setExecutionToCancel] = useState<string | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const {data: workflow, mutate} = useSWR<WorkflowWithDetails>(id ? `/workflows/${id}` : null, {
revalidateOnFocus: false,
@@ -69,9 +73,181 @@ export default function WorkflowEditorPage() {
{revalidateOnFocus: false},
);
// Always fetch a summary of active executions to show warnings (regardless of enabled status)
const {data: activeExecutionsData} = useSWR<PaginatedExecutions>(
id ? `/workflows/${id}/executions?page=1&pageSize=1&status=RUNNING` : null,
{revalidateOnFocus: false, refreshInterval: 10000},
);
const {data: waitingExecutionsData} = useSWR<PaginatedExecutions>(
id ? `/workflows/${id}/executions?page=1&pageSize=1&status=WAITING` : null,
{revalidateOnFocus: false, refreshInterval: 10000},
);
// Check for active executions
const activeExecutionsCount = (activeExecutionsData?.total || 0) + (waitingExecutionsData?.total || 0);
// Handler for cancelling a single execution
const handleCancelExecution = async (executionId: string) => {
setIsCancelling(true);
try {
await network.fetch('DELETE', `/workflows/${id}/executions/${executionId}`);
toast.success('Execution cancelled successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to cancel execution');
} finally {
setIsCancelling(false);
setExecutionToCancel(null);
}
};
// Handler for cancelling all executions
const handleCancelAllExecutions = async () => {
setIsCancelling(true);
try {
const result = await network.fetch<{cancelled: number}>('POST', `/workflows/${id}/executions/cancel-all`);
toast.success(`Successfully cancelled ${result.cancelled} execution(s)`);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to cancel executions');
} finally {
setIsCancelling(false);
setShowCancelAllDialog(false);
}
};
// Validate workflow configuration
const validateWorkflow = (workflow: WorkflowWithDetails): {valid: boolean; errors: string[]} => {
const errors: string[] = [];
// Check if there are any steps
if (workflow.steps.length === 0) {
errors.push('Workflow must have at least one step');
return {valid: false, errors};
}
// Validate each step
workflow.steps.forEach(step => {
const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {};
switch (step.type) {
case 'SEND_EMAIL':
if (!step.templateId) {
errors.push(`"${step.name}" step is missing an email template`);
}
break;
case 'DELAY':
if (!config.amount || !config.unit) {
errors.push(`"${step.name}" step is missing delay configuration (amount or unit)`);
}
break;
case 'CONDITION':
// Extract field name from both legacy format (object) and new format (string)
let fieldValue = '';
if (config.field) {
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
fieldValue = String(config.field.field || '');
} else {
fieldValue = String(config.field);
}
}
if (!fieldValue || !config.operator) {
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
}
// Check if value is required for this operator
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
errors.push(`"${step.name}" step is missing a value for the condition`);
}
break;
case 'WAIT_FOR_EVENT':
if (!config.eventName) {
errors.push(`"${step.name}" step is missing event name`);
}
break;
case 'WEBHOOK':
if (!config.url) {
errors.push(`"${step.name}" step is missing webhook URL`);
}
break;
case 'UPDATE_CONTACT':
if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) {
errors.push(`"${step.name}" step is missing contact updates`);
}
break;
}
});
// Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT)
const triggerSteps = workflow.steps.filter(s => s.type === 'TRIGGER');
const exitSteps = workflow.steps.filter(s => s.type === 'EXIT');
workflow.steps.forEach(step => {
if (step.type !== 'TRIGGER' && step.type !== 'EXIT') {
const hasIncoming = step.incomingTransitions && step.incomingTransitions.length > 0;
const hasOutgoing = step.outgoingTransitions && step.outgoingTransitions.length > 0;
if (!hasIncoming && !hasOutgoing) {
errors.push(`"${step.name}" step is not connected to the workflow`);
}
}
});
// Check if there's a TRIGGER step
if (triggerSteps.length === 0) {
errors.push('Workflow must have a trigger step');
}
// Check for CONDITION steps that don't have both yes and no branches
workflow.steps.forEach(step => {
if (step.type === 'CONDITION' && step.outgoingTransitions) {
const hasYesBranch = step.outgoingTransitions.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
});
const hasNoBranch = step.outgoingTransitions.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
});
if (!hasYesBranch || !hasNoBranch) {
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
}
}
});
return {valid: errors.length === 0, errors};
};
const handleToggleEnabled = async () => {
if (!workflow) return;
// If trying to enable, validate first
if (!workflow.enabled) {
const validation = validateWorkflow(workflow);
if (!validation.valid) {
toast.error(
<div>
<div className="font-semibold mb-1">Cannot enable workflow</div>
<ul className="list-disc list-inside text-sm">
{validation.errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>,
{duration: 8000},
);
return;
}
}
try {
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${id}`, {
enabled: !workflow.enabled,
@@ -198,6 +374,79 @@ export default function WorkflowEditorPage() {
</div>
</div>
{/* Active Executions Warning Banner */}
{activeExecutionsCount > 0 && (
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded-r-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">
{workflow.enabled ? 'Workflow is active with running executions' : 'Workflow has active executions'}
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
This workflow has <strong>{activeExecutionsCount}</strong> active execution
{activeExecutionsCount !== 1 ? 's' : ''}.{' '}
{!workflow.enabled && 'Even though the workflow is disabled, existing executions will continue. '}
To protect running workflows, you cannot:
</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li>Delete steps or transitions</li>
<li>Modify step configurations (email templates, conditions, etc.)</li>
<li>Change the workflow trigger</li>
</ul>
<p className="mt-2">
You can still rename steps and adjust their position. To make configuration changes, wait for
executions to complete or cancel them from the Executions tab.
</p>
</div>
</div>
</div>
</div>
)}
{/* Validation Warning Banner */}
{!workflow.enabled && (() => {
const validation = validateWorkflow(workflow);
if (!validation.valid) {
return (
<div className="bg-amber-50 border-l-4 border-amber-400 p-4 rounded-r-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">Workflow has validation errors</h3>
<div className="mt-2 text-sm text-amber-700">
<p className="mb-2">Fix the following issues before enabling this workflow:</p>
<ul className="list-disc list-inside space-y-1">
{validation.errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
return null;
})()}
{/* Tabs */}
<div className="border-b border-neutral-200">
<nav className="-mb-px flex space-x-8">
@@ -221,18 +470,6 @@ export default function WorkflowEditorPage() {
>
Executions
</button>
{process.env.NODE_ENV === 'development' && (
<button
onClick={() => setActiveTab('debug')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'debug'
? 'border-neutral-900 text-neutral-900'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
Debug
</button>
)}
</nav>
</div>
@@ -254,8 +491,17 @@ export default function WorkflowEditorPage() {
) : activeTab === 'executions' ? (
<Card>
<CardHeader>
<CardTitle>Workflow Executions</CardTitle>
<CardDescription>View all executions of this workflow</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle>Workflow Executions</CardTitle>
<CardDescription>View and manage all executions of this workflow</CardDescription>
</div>
{activeExecutionsCount > 0 && (
<Button variant="outline" onClick={() => setShowCancelAllDialog(true)}>
Cancel All Active ({activeExecutionsCount})
</Button>
)}
</div>
</CardHeader>
<CardContent>
{!executionsData?.executions.length ? (
@@ -283,6 +529,9 @@ export default function WorkflowEditorPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Started
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
@@ -298,9 +547,13 @@ export default function WorkflowEditorPage() {
? 'bg-green-100 text-green-800'
: execution.status === 'RUNNING'
? 'bg-blue-100 text-blue-800'
: execution.status === 'FAILED'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
: execution.status === 'WAITING'
? 'bg-yellow-100 text-yellow-800'
: execution.status === 'FAILED'
? 'bg-red-100 text-red-800'
: execution.status === 'CANCELLED'
? 'bg-gray-100 text-gray-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{execution.status}
@@ -312,6 +565,17 @@ export default function WorkflowEditorPage() {
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(execution.startedAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{(execution.status === 'RUNNING' || execution.status === 'WAITING') && (
<button
onClick={() => setExecutionToCancel(execution.id)}
className="text-red-600 hover:text-red-900 disabled:opacity-50"
disabled={isCancelling}
>
Cancel
</button>
)}
</td>
</tr>
))}
</tbody>
@@ -320,105 +584,6 @@ export default function WorkflowEditorPage() {
)}
</CardContent>
</Card>
) : activeTab === 'debug' ? (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Debug Information</CardTitle>
<CardDescription>Raw workflow data for debugging</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Workflow Steps */}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Steps</h3>
<pre className="bg-neutral-50 p-4 rounded-lg text-xs overflow-x-auto">
{JSON.stringify(
workflow.steps.map(s => ({
id: s.id,
type: s.type,
name: s.name,
position: s.position,
config: s.config,
templateId: s.templateId,
})),
null,
2,
)}
</pre>
</div>
{/* All Transitions */}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-3">All Transitions</h3>
<pre className="bg-neutral-50 p-4 rounded-lg text-xs overflow-x-auto">
{JSON.stringify(
workflow.steps.flatMap(step =>
step.outgoingTransitions.map(t => ({
id: t.id,
fromStepId: t.fromStepId,
fromStepName: step.name,
toStepId: t.toStepId,
toStepName: workflow.steps.find(s => s.id === t.toStepId)?.name,
condition: t.condition,
priority: t.priority,
})),
),
null,
2,
)}
</pre>
</div>
{/* Transition Analysis */}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Transition Analysis</h3>
<div className="space-y-3">
{workflow.steps.map(step => {
if (step.outgoingTransitions.length === 0) return null;
return (
<div key={step.id} className="border border-neutral-200 rounded-lg p-3">
<div className="font-medium text-sm text-neutral-900 mb-2">
{step.name} ({step.type})
</div>
<div className="space-y-1 text-xs">
{step.outgoingTransitions.map(t => {
const toStep = workflow.steps.find(s => s.id === t.toStepId);
const branch =
t.condition && typeof t.condition === 'object' && 'branch' in t.condition
? t.condition.branch
: undefined;
return (
<div
key={t.id}
className={`flex items-start gap-2 ${
branch === 'yes'
? 'text-green-700 bg-green-50'
: branch === 'no'
? 'text-red-700 bg-red-50'
: 'text-neutral-700 bg-neutral-50'
} p-2 rounded`}
>
<span className="font-mono flex-shrink-0">
{branch === 'yes' ? '✓ YES' : branch === 'no' ? '✗ NO' : '→'}
</span>
<div className="flex-1">
<div> {toStep?.name || 'Unknown'}</div>
<div className="text-neutral-500 mt-1">
Priority: {t.priority} | ID: {t.id}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
@@ -441,6 +606,65 @@ export default function WorkflowEditorPage() {
onSuccess={() => mutate()}
/>
)}
{/* Cancel Single Execution Confirmation */}
<ConfirmDialog
open={!!executionToCancel}
onOpenChange={open => !open && setExecutionToCancel(null)}
onConfirm={() => {
if (executionToCancel) {
return handleCancelExecution(executionToCancel);
}
}}
title="Cancel Execution"
description={
executionToCancel && executionsData?.executions ? (
<div className="space-y-2">
<p>
Are you sure you want to cancel the workflow execution for{' '}
<strong>
{executionsData.executions.find(e => e.id === executionToCancel)?.contact.email || 'this contact'}
</strong>
?
</p>
<p className="text-sm text-neutral-600">
The contact will not receive any remaining emails or actions from this workflow. This action cannot
be undone.
</p>
</div>
) : (
'Are you sure you want to cancel this execution?'
)
}
confirmText="Cancel Execution"
cancelText="Keep Running"
variant="destructive"
isLoading={isCancelling}
/>
{/* Cancel All Executions Confirmation */}
<ConfirmDialog
open={showCancelAllDialog}
onOpenChange={setShowCancelAllDialog}
onConfirm={handleCancelAllExecutions}
title="Cancel All Active Executions"
description={
<div className="space-y-2">
<p>
Are you sure you want to cancel all <strong>{activeExecutionsCount}</strong> active execution
{activeExecutionsCount !== 1 ? 's' : ''}?
</p>
<p className="text-sm text-neutral-600">
All contacts currently in this workflow will be stopped and won&apos;t receive any remaining emails or
actions. This action cannot be undone.
</p>
</div>
}
confirmText={`Cancel ${activeExecutionsCount} Execution${activeExecutionsCount !== 1 ? 's' : ''}`}
cancelText="Keep Running"
variant="destructive"
isLoading={isCancelling}
/>
</>
)}
</DashboardLayout>
@@ -1146,8 +1370,16 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
| 'minutes',
);
// CONDITION fields
const [conditionField, setConditionField] = useState(String(config?.field || ''));
// CONDITION fields - handle both old format (object) and new format (string)
const [conditionField, setConditionField] = useState(() => {
if (!config?.field) return '';
// Handle case where field is an object like {field: 'email', type: 'string'} (legacy format)
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
return String(config.field.field || '');
}
// Handle new format where field is just a string
return String(config.field);
});
const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals'));
const [conditionValue, setConditionValue] = useState(String(config?.value ?? ''));
const [availableFields, setAvailableFields] = useState<string[]>([]);