Add better segmenting
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user