@startsimpli/ui 0.4.22 → 0.4.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/workflows/WorkflowListComposer.tsx +644 -0
- package/src/components/workflows/WorkflowSettingsCard.tsx +208 -0
- package/src/components/workflows/WorkflowStatsOverview.tsx +175 -0
- package/src/components/workflows/WorkflowTemplateGallery.tsx +608 -0
- package/src/components/workflows/index.ts +24 -0
package/package.json
CHANGED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState, type ReactNode } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Plus,
|
|
6
|
+
MoreVertical,
|
|
7
|
+
Play,
|
|
8
|
+
Copy,
|
|
9
|
+
Trash2,
|
|
10
|
+
GitBranch,
|
|
11
|
+
Cpu,
|
|
12
|
+
Activity,
|
|
13
|
+
Search,
|
|
14
|
+
} from 'lucide-react';
|
|
15
|
+
import { Button } from '../ui/button';
|
|
16
|
+
import { Input } from '../ui/input';
|
|
17
|
+
import { Label } from '../ui/label';
|
|
18
|
+
import { Textarea } from '../ui/textarea';
|
|
19
|
+
import { Badge } from '../ui/badge';
|
|
20
|
+
import {
|
|
21
|
+
Dialog,
|
|
22
|
+
DialogContent,
|
|
23
|
+
DialogDescription,
|
|
24
|
+
DialogFooter,
|
|
25
|
+
DialogHeader,
|
|
26
|
+
DialogTitle,
|
|
27
|
+
} from '../ui/dialog';
|
|
28
|
+
import {
|
|
29
|
+
DropdownMenu,
|
|
30
|
+
DropdownMenuContent,
|
|
31
|
+
DropdownMenuItem,
|
|
32
|
+
DropdownMenuSeparator,
|
|
33
|
+
DropdownMenuTrigger,
|
|
34
|
+
} from '../ui/dropdown-menu';
|
|
35
|
+
import {
|
|
36
|
+
Table,
|
|
37
|
+
TableBody,
|
|
38
|
+
TableCell,
|
|
39
|
+
TableHead,
|
|
40
|
+
TableHeader,
|
|
41
|
+
TableRow,
|
|
42
|
+
} from '../ui/table';
|
|
43
|
+
import { Tabs, TabsList, TabsTrigger } from '../ui/tabs';
|
|
44
|
+
import { cn } from '../../lib/utils';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Minimal, in-package shape of a workflow list row. Mirrors the subset of the
|
|
48
|
+
* backend list item this composer reads — kept local so the component carries
|
|
49
|
+
* no app type coupling.
|
|
50
|
+
*/
|
|
51
|
+
export interface WorkflowListItem {
|
|
52
|
+
uuid: string;
|
|
53
|
+
name: string;
|
|
54
|
+
description?: string | null;
|
|
55
|
+
isBrain?: boolean;
|
|
56
|
+
isTemplate?: boolean;
|
|
57
|
+
nodeCount: number;
|
|
58
|
+
version: number | string;
|
|
59
|
+
lastExecutionStatus?: string | null;
|
|
60
|
+
lastExecutionAt?: string | null;
|
|
61
|
+
lastMod: string;
|
|
62
|
+
executionCount?: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Payload for creating a new workflow. */
|
|
66
|
+
export interface WorkflowCreateInput {
|
|
67
|
+
name: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
isBrain?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WorkflowListComposerProps {
|
|
73
|
+
/** Workflow rows to render. */
|
|
74
|
+
workflows: WorkflowListItem[];
|
|
75
|
+
/** Whether the list is loading (drives skeleton/empty messaging). */
|
|
76
|
+
isLoading?: boolean;
|
|
77
|
+
/** Error message to surface, if any. */
|
|
78
|
+
error?: string | null;
|
|
79
|
+
/** Navigate to a workflow's detail/canvas view. */
|
|
80
|
+
onNavigateToDetail: (uuid: string) => void;
|
|
81
|
+
/** Navigate to a workflow's executions view. Falls back to detail when omitted. */
|
|
82
|
+
onNavigateToExecutions?: (uuid: string) => void;
|
|
83
|
+
/** Delete a workflow by id. */
|
|
84
|
+
onDelete: (uuid: string) => void | Promise<void>;
|
|
85
|
+
/** Create a workflow from the inline create dialog. */
|
|
86
|
+
onCreate: (input: WorkflowCreateInput) => void | Promise<void>;
|
|
87
|
+
/** Execute a workflow by id (optional — Execute action hidden when omitted). */
|
|
88
|
+
onExecute?: (uuid: string) => void | Promise<void>;
|
|
89
|
+
/** Duplicate a workflow by id (optional — Duplicate action hidden when omitted). */
|
|
90
|
+
onClone?: (uuid: string) => void | Promise<void>;
|
|
91
|
+
/** Re-fetch the list (e.g. after search/filter/tab changes). */
|
|
92
|
+
onRefresh?: (params: {
|
|
93
|
+
tab: 'workflows' | 'brains';
|
|
94
|
+
search: string;
|
|
95
|
+
status: string;
|
|
96
|
+
}) => void;
|
|
97
|
+
/** Formats an ISO timestamp for display. Defaults to the raw string. */
|
|
98
|
+
formatTimestamp?: (timestamp: string) => string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const STATUS_OPTIONS = [
|
|
102
|
+
{ label: 'All', value: 'all' },
|
|
103
|
+
{ label: 'Active', value: 'active' },
|
|
104
|
+
{ label: 'Inactive', value: 'inactive' },
|
|
105
|
+
{ label: 'Templates', value: 'template' },
|
|
106
|
+
] as const;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Inline status badge — substitute for the app's `StatusBadge` primitive
|
|
110
|
+
* (not part of @startsimpli/ui). Maps common execution status slugs to a
|
|
111
|
+
* theme-aware Badge color.
|
|
112
|
+
*/
|
|
113
|
+
function StatusBadge({ status }: { status: string }) {
|
|
114
|
+
const tone: Record<string, string> = {
|
|
115
|
+
completed:
|
|
116
|
+
'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400',
|
|
117
|
+
success:
|
|
118
|
+
'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400',
|
|
119
|
+
running:
|
|
120
|
+
'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
|
|
121
|
+
failed:
|
|
122
|
+
'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400',
|
|
123
|
+
error:
|
|
124
|
+
'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400',
|
|
125
|
+
pending:
|
|
126
|
+
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400',
|
|
127
|
+
waiting:
|
|
128
|
+
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400',
|
|
129
|
+
};
|
|
130
|
+
return (
|
|
131
|
+
<Badge
|
|
132
|
+
variant="outline"
|
|
133
|
+
className={cn('capitalize', tone[status.toLowerCase()])}
|
|
134
|
+
>
|
|
135
|
+
{status}
|
|
136
|
+
</Badge>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function WorkflowListComposer({
|
|
141
|
+
workflows,
|
|
142
|
+
isLoading = false,
|
|
143
|
+
error = null,
|
|
144
|
+
onNavigateToDetail,
|
|
145
|
+
onNavigateToExecutions,
|
|
146
|
+
onDelete,
|
|
147
|
+
onCreate,
|
|
148
|
+
onExecute,
|
|
149
|
+
onClone,
|
|
150
|
+
onRefresh,
|
|
151
|
+
formatTimestamp,
|
|
152
|
+
}: WorkflowListComposerProps) {
|
|
153
|
+
const [activeTab, setActiveTab] = useState<'workflows' | 'brains'>(
|
|
154
|
+
'workflows'
|
|
155
|
+
);
|
|
156
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
157
|
+
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
158
|
+
|
|
159
|
+
// Create dialog state
|
|
160
|
+
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
161
|
+
const [createForm, setCreateForm] = useState<WorkflowCreateInput>({
|
|
162
|
+
name: '',
|
|
163
|
+
});
|
|
164
|
+
const [creating, setCreating] = useState(false);
|
|
165
|
+
const [createNameError, setCreateNameError] = useState<string | null>(null);
|
|
166
|
+
|
|
167
|
+
// Delete dialog state
|
|
168
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
169
|
+
const [workflowToDelete, setWorkflowToDelete] =
|
|
170
|
+
useState<WorkflowListItem | null>(null);
|
|
171
|
+
|
|
172
|
+
const isBrains = activeTab === 'brains';
|
|
173
|
+
const formatTime = formatTimestamp ?? ((t: string) => t);
|
|
174
|
+
|
|
175
|
+
// Client-side filtering over the controlled `workflows` prop. The consumer
|
|
176
|
+
// may also refetch via onRefresh; this keeps the UI responsive in between.
|
|
177
|
+
const visibleWorkflows = useMemo(() => {
|
|
178
|
+
const q = searchQuery.trim().toLowerCase();
|
|
179
|
+
return workflows.filter((w) => {
|
|
180
|
+
if (isBrains ? w.isBrain !== true : w.isBrain === true) return false;
|
|
181
|
+
if (statusFilter === 'template' && !w.isTemplate) return false;
|
|
182
|
+
if (q) {
|
|
183
|
+
const haystack = `${w.name} ${w.description ?? ''}`.toLowerCase();
|
|
184
|
+
if (!haystack.includes(q)) return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
});
|
|
188
|
+
}, [workflows, isBrains, statusFilter, searchQuery]);
|
|
189
|
+
|
|
190
|
+
const notifyRefresh = (
|
|
191
|
+
next: Partial<{
|
|
192
|
+
tab: 'workflows' | 'brains';
|
|
193
|
+
search: string;
|
|
194
|
+
status: string;
|
|
195
|
+
}>
|
|
196
|
+
) => {
|
|
197
|
+
onRefresh?.({
|
|
198
|
+
tab: next.tab ?? activeTab,
|
|
199
|
+
search: next.search ?? searchQuery,
|
|
200
|
+
status: next.status ?? statusFilter,
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const handleTabChange = (v: string) => {
|
|
205
|
+
const tab = v as 'workflows' | 'brains';
|
|
206
|
+
setActiveTab(tab);
|
|
207
|
+
setSearchQuery('');
|
|
208
|
+
setStatusFilter('all');
|
|
209
|
+
notifyRefresh({ tab, search: '', status: 'all' });
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const handleSearch = (value: string) => {
|
|
213
|
+
setSearchQuery(value);
|
|
214
|
+
notifyRefresh({ search: value });
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const handleStatusChange = (value: string) => {
|
|
218
|
+
setStatusFilter(value);
|
|
219
|
+
notifyRefresh({ status: value });
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handleCreate = async () => {
|
|
223
|
+
if (!createForm.name.trim()) {
|
|
224
|
+
setCreateNameError('Name is required');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
setCreateNameError(null);
|
|
228
|
+
try {
|
|
229
|
+
setCreating(true);
|
|
230
|
+
await onCreate({ ...createForm, isBrain: isBrains });
|
|
231
|
+
setCreateDialogOpen(false);
|
|
232
|
+
setCreateForm({ name: '' });
|
|
233
|
+
} finally {
|
|
234
|
+
setCreating(false);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const handleDeleteConfirm = async () => {
|
|
239
|
+
if (!workflowToDelete) return;
|
|
240
|
+
try {
|
|
241
|
+
await onDelete(workflowToDelete.uuid);
|
|
242
|
+
} finally {
|
|
243
|
+
setDeleteDialogOpen(false);
|
|
244
|
+
setWorkflowToDelete(null);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const emptyMessage = searchQuery
|
|
249
|
+
? `No ${isBrains ? 'brains' : 'workflows'} match your search`
|
|
250
|
+
: `No ${isBrains ? 'brains' : 'workflows'} yet. Create your first ${isBrains ? 'brain' : 'workflow'} to get started.`;
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className="p-6 space-y-6">
|
|
254
|
+
{/* Page header */}
|
|
255
|
+
<div className="flex justify-between items-center">
|
|
256
|
+
<div>
|
|
257
|
+
<h1 className="text-3xl font-bold flex items-center gap-2">
|
|
258
|
+
{isBrains ? (
|
|
259
|
+
<Cpu className="h-7 w-7" />
|
|
260
|
+
) : (
|
|
261
|
+
<GitBranch className="h-7 w-7" />
|
|
262
|
+
)}
|
|
263
|
+
{isBrains ? 'Brains' : 'Workflows'}
|
|
264
|
+
</h1>
|
|
265
|
+
<p className="text-muted-foreground mt-1">
|
|
266
|
+
{isBrains
|
|
267
|
+
? 'Self-contained think/decide/act loops that run inside workflows'
|
|
268
|
+
: 'Top-level execution loops that orchestrate your agents'}
|
|
269
|
+
</p>
|
|
270
|
+
</div>
|
|
271
|
+
<Button
|
|
272
|
+
onClick={() => setCreateDialogOpen(true)}
|
|
273
|
+
data-testid={isBrains ? 'new-brain-button' : 'new-workflow-button'}
|
|
274
|
+
aria-label={isBrains ? 'New brain' : 'New workflow'}
|
|
275
|
+
>
|
|
276
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
277
|
+
{isBrains ? 'New Brain' : 'New Workflow'}
|
|
278
|
+
</Button>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
|
282
|
+
<TabsList>
|
|
283
|
+
<TabsTrigger
|
|
284
|
+
value="workflows"
|
|
285
|
+
className="gap-2"
|
|
286
|
+
data-testid="workflows-tab"
|
|
287
|
+
>
|
|
288
|
+
<GitBranch className="h-4 w-4" />
|
|
289
|
+
Workflows
|
|
290
|
+
</TabsTrigger>
|
|
291
|
+
<TabsTrigger value="brains" className="gap-2" data-testid="brains-tab">
|
|
292
|
+
<Cpu className="h-4 w-4" />
|
|
293
|
+
Brains
|
|
294
|
+
</TabsTrigger>
|
|
295
|
+
</TabsList>
|
|
296
|
+
</Tabs>
|
|
297
|
+
|
|
298
|
+
{/* Search + status filter (inline substitute for SearchFilterBar) */}
|
|
299
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
300
|
+
<div className="relative w-full sm:max-w-sm">
|
|
301
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
302
|
+
<Input
|
|
303
|
+
value={searchQuery}
|
|
304
|
+
onChange={(e) => handleSearch(e.target.value)}
|
|
305
|
+
placeholder={
|
|
306
|
+
isBrains
|
|
307
|
+
? 'Search brains by name or description...'
|
|
308
|
+
: 'Search workflows by name or description...'
|
|
309
|
+
}
|
|
310
|
+
className="pl-9"
|
|
311
|
+
/>
|
|
312
|
+
</div>
|
|
313
|
+
<div className="flex items-center gap-1 rounded-lg border bg-muted/50 p-0.5">
|
|
314
|
+
{STATUS_OPTIONS.map((opt) => (
|
|
315
|
+
<Button
|
|
316
|
+
key={opt.value}
|
|
317
|
+
variant="ghost"
|
|
318
|
+
size="sm"
|
|
319
|
+
className={cn(
|
|
320
|
+
'h-7 px-3 text-xs font-medium',
|
|
321
|
+
statusFilter === opt.value
|
|
322
|
+
? 'bg-background shadow-sm text-foreground'
|
|
323
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
324
|
+
)}
|
|
325
|
+
onClick={() => handleStatusChange(opt.value)}
|
|
326
|
+
>
|
|
327
|
+
{opt.label}
|
|
328
|
+
</Button>
|
|
329
|
+
))}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* Data table (inline substitute for DataTable) */}
|
|
334
|
+
<div className="rounded-lg border">
|
|
335
|
+
<Table>
|
|
336
|
+
<TableHeader>
|
|
337
|
+
<TableRow>
|
|
338
|
+
<TableHead>Workflow</TableHead>
|
|
339
|
+
<TableHead className="text-center w-20">Nodes</TableHead>
|
|
340
|
+
<TableHead className="text-center w-20">Version</TableHead>
|
|
341
|
+
<TableHead className="w-24">Status</TableHead>
|
|
342
|
+
<TableHead>Last Run</TableHead>
|
|
343
|
+
<TableHead>Last Edit</TableHead>
|
|
344
|
+
<TableHead>Executions</TableHead>
|
|
345
|
+
<TableHead>Actions</TableHead>
|
|
346
|
+
</TableRow>
|
|
347
|
+
</TableHeader>
|
|
348
|
+
<TableBody>
|
|
349
|
+
{isLoading ? (
|
|
350
|
+
<TableRow>
|
|
351
|
+
<TableCell
|
|
352
|
+
colSpan={8}
|
|
353
|
+
className="py-12 text-center text-muted-foreground"
|
|
354
|
+
>
|
|
355
|
+
Loading…
|
|
356
|
+
</TableCell>
|
|
357
|
+
</TableRow>
|
|
358
|
+
) : error ? (
|
|
359
|
+
<TableRow>
|
|
360
|
+
<TableCell
|
|
361
|
+
colSpan={8}
|
|
362
|
+
className="py-12 text-center text-destructive"
|
|
363
|
+
>
|
|
364
|
+
{error}
|
|
365
|
+
</TableCell>
|
|
366
|
+
</TableRow>
|
|
367
|
+
) : visibleWorkflows.length === 0 ? (
|
|
368
|
+
<TableRow>
|
|
369
|
+
<TableCell
|
|
370
|
+
colSpan={8}
|
|
371
|
+
className="py-12 text-center text-muted-foreground"
|
|
372
|
+
>
|
|
373
|
+
{emptyMessage}
|
|
374
|
+
</TableCell>
|
|
375
|
+
</TableRow>
|
|
376
|
+
) : (
|
|
377
|
+
visibleWorkflows.map((row) => (
|
|
378
|
+
<TableRow
|
|
379
|
+
key={row.uuid}
|
|
380
|
+
className="cursor-pointer"
|
|
381
|
+
onClick={() => onNavigateToDetail(row.uuid)}
|
|
382
|
+
>
|
|
383
|
+
<TableCell>
|
|
384
|
+
<div>
|
|
385
|
+
<div className="font-medium flex items-center gap-1.5">
|
|
386
|
+
{row.isBrain && (
|
|
387
|
+
<Cpu className="h-3.5 w-3.5 text-teal-500 shrink-0" />
|
|
388
|
+
)}
|
|
389
|
+
{row.name}
|
|
390
|
+
</div>
|
|
391
|
+
{row.description && (
|
|
392
|
+
<div className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
|
|
393
|
+
{row.description}
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
{row.isTemplate && (
|
|
397
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-indigo-100 text-indigo-800 dark:bg-indigo-800/20 dark:text-indigo-300 mt-1">
|
|
398
|
+
template
|
|
399
|
+
</span>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
</TableCell>
|
|
403
|
+
<TableCell className="text-center w-20">
|
|
404
|
+
<span className="text-sm">{row.nodeCount}</span>
|
|
405
|
+
</TableCell>
|
|
406
|
+
<TableCell className="text-center w-20">
|
|
407
|
+
<span className="text-sm font-mono">v{row.version}</span>
|
|
408
|
+
</TableCell>
|
|
409
|
+
<TableCell className="w-24">
|
|
410
|
+
{row.lastExecutionStatus ? (
|
|
411
|
+
<StatusBadge status={row.lastExecutionStatus} />
|
|
412
|
+
) : (
|
|
413
|
+
<span className="text-sm text-muted-foreground">—</span>
|
|
414
|
+
)}
|
|
415
|
+
</TableCell>
|
|
416
|
+
<TableCell>
|
|
417
|
+
{row.lastExecutionAt ? (
|
|
418
|
+
<span className="text-sm text-muted-foreground">
|
|
419
|
+
{formatTime(row.lastExecutionAt)}
|
|
420
|
+
</span>
|
|
421
|
+
) : (
|
|
422
|
+
<span className="text-sm text-muted-foreground">—</span>
|
|
423
|
+
)}
|
|
424
|
+
</TableCell>
|
|
425
|
+
<TableCell>
|
|
426
|
+
<span className="text-sm text-muted-foreground">
|
|
427
|
+
{formatTime(row.lastMod)}
|
|
428
|
+
</span>
|
|
429
|
+
</TableCell>
|
|
430
|
+
<TableCell>
|
|
431
|
+
<Button
|
|
432
|
+
variant="outline"
|
|
433
|
+
size="sm"
|
|
434
|
+
className="h-7 px-2.5 text-xs rounded-full"
|
|
435
|
+
onClick={(e) => {
|
|
436
|
+
e.stopPropagation();
|
|
437
|
+
(onNavigateToExecutions ?? onNavigateToDetail)(
|
|
438
|
+
row.uuid
|
|
439
|
+
);
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
<Activity className="mr-1 h-3 w-3" />
|
|
443
|
+
{row.executionCount ?? 0}
|
|
444
|
+
</Button>
|
|
445
|
+
</TableCell>
|
|
446
|
+
<TableCell>
|
|
447
|
+
<RowActions
|
|
448
|
+
row={row}
|
|
449
|
+
onExecute={onExecute}
|
|
450
|
+
onClone={onClone}
|
|
451
|
+
onDeleteClick={() => {
|
|
452
|
+
setWorkflowToDelete(row);
|
|
453
|
+
setDeleteDialogOpen(true);
|
|
454
|
+
}}
|
|
455
|
+
/>
|
|
456
|
+
</TableCell>
|
|
457
|
+
</TableRow>
|
|
458
|
+
))
|
|
459
|
+
)}
|
|
460
|
+
</TableBody>
|
|
461
|
+
</Table>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
{/* Create Workflow Dialog */}
|
|
465
|
+
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
|
466
|
+
<DialogContent>
|
|
467
|
+
<DialogHeader>
|
|
468
|
+
<DialogTitle>
|
|
469
|
+
{isBrains ? 'Create Brain' : 'Create Workflow'}
|
|
470
|
+
</DialogTitle>
|
|
471
|
+
<DialogDescription>
|
|
472
|
+
Create a new workflow definition. You can add nodes on the canvas
|
|
473
|
+
after creation.
|
|
474
|
+
</DialogDescription>
|
|
475
|
+
</DialogHeader>
|
|
476
|
+
<div className="space-y-4 py-2">
|
|
477
|
+
<div className="space-y-2">
|
|
478
|
+
<Label htmlFor="workflow-name">Name *</Label>
|
|
479
|
+
<Input
|
|
480
|
+
id="workflow-name"
|
|
481
|
+
name="workflow-name"
|
|
482
|
+
data-testid="workflow-name-input"
|
|
483
|
+
placeholder="Login Flow Automation"
|
|
484
|
+
value={createForm.name}
|
|
485
|
+
onChange={(e) => {
|
|
486
|
+
setCreateForm((prev) => ({ ...prev, name: e.target.value }));
|
|
487
|
+
if (createNameError) setCreateNameError(null);
|
|
488
|
+
}}
|
|
489
|
+
aria-invalid={createNameError ? 'true' : 'false'}
|
|
490
|
+
aria-describedby={
|
|
491
|
+
createNameError ? 'workflow-name-error' : undefined
|
|
492
|
+
}
|
|
493
|
+
className={
|
|
494
|
+
createNameError
|
|
495
|
+
? 'border-red-500 focus-visible:ring-red-500'
|
|
496
|
+
: ''
|
|
497
|
+
}
|
|
498
|
+
/>
|
|
499
|
+
{createNameError && (
|
|
500
|
+
<p
|
|
501
|
+
id="workflow-name-error"
|
|
502
|
+
role="alert"
|
|
503
|
+
className="text-sm text-red-600"
|
|
504
|
+
>
|
|
505
|
+
{createNameError}
|
|
506
|
+
</p>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
<div className="space-y-2">
|
|
510
|
+
<Label htmlFor="workflow-desc">Description</Label>
|
|
511
|
+
<Textarea
|
|
512
|
+
id="workflow-desc"
|
|
513
|
+
name="workflow-desc"
|
|
514
|
+
data-testid="workflow-description-input"
|
|
515
|
+
placeholder="Logs in users and navigates to dashboard"
|
|
516
|
+
value={createForm.description || ''}
|
|
517
|
+
onChange={(e) =>
|
|
518
|
+
setCreateForm((prev) => ({
|
|
519
|
+
...prev,
|
|
520
|
+
description: e.target.value,
|
|
521
|
+
}))
|
|
522
|
+
}
|
|
523
|
+
rows={3}
|
|
524
|
+
/>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
<DialogFooter>
|
|
528
|
+
<Button
|
|
529
|
+
variant="outline"
|
|
530
|
+
onClick={() => setCreateDialogOpen(false)}
|
|
531
|
+
data-testid="workflow-create-cancel"
|
|
532
|
+
>
|
|
533
|
+
Cancel
|
|
534
|
+
</Button>
|
|
535
|
+
<Button
|
|
536
|
+
onClick={handleCreate}
|
|
537
|
+
disabled={creating}
|
|
538
|
+
data-testid="workflow-create-submit"
|
|
539
|
+
>
|
|
540
|
+
{creating
|
|
541
|
+
? 'Creating...'
|
|
542
|
+
: isBrains
|
|
543
|
+
? 'Create Brain'
|
|
544
|
+
: 'Create Workflow'}
|
|
545
|
+
</Button>
|
|
546
|
+
</DialogFooter>
|
|
547
|
+
</DialogContent>
|
|
548
|
+
</Dialog>
|
|
549
|
+
|
|
550
|
+
{/* Delete confirmation dialog (inline substitute for AlertDialog) */}
|
|
551
|
+
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
552
|
+
<DialogContent>
|
|
553
|
+
<DialogHeader>
|
|
554
|
+
<DialogTitle>Delete Workflow</DialogTitle>
|
|
555
|
+
<DialogDescription>
|
|
556
|
+
Are you sure you want to delete{' '}
|
|
557
|
+
<strong>{workflowToDelete?.name}</strong>?
|
|
558
|
+
<br />
|
|
559
|
+
This action cannot be undone. All associated versions and execution
|
|
560
|
+
history will be permanently deleted.
|
|
561
|
+
</DialogDescription>
|
|
562
|
+
</DialogHeader>
|
|
563
|
+
<DialogFooter>
|
|
564
|
+
<Button
|
|
565
|
+
variant="outline"
|
|
566
|
+
onClick={() => setDeleteDialogOpen(false)}
|
|
567
|
+
>
|
|
568
|
+
Cancel
|
|
569
|
+
</Button>
|
|
570
|
+
<Button
|
|
571
|
+
onClick={handleDeleteConfirm}
|
|
572
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
573
|
+
>
|
|
574
|
+
Delete
|
|
575
|
+
</Button>
|
|
576
|
+
</DialogFooter>
|
|
577
|
+
</DialogContent>
|
|
578
|
+
</Dialog>
|
|
579
|
+
</div>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function RowActions({
|
|
584
|
+
row,
|
|
585
|
+
onExecute,
|
|
586
|
+
onClone,
|
|
587
|
+
onDeleteClick,
|
|
588
|
+
}: {
|
|
589
|
+
row: WorkflowListItem;
|
|
590
|
+
onExecute?: (uuid: string) => void | Promise<void>;
|
|
591
|
+
onClone?: (uuid: string) => void | Promise<void>;
|
|
592
|
+
onDeleteClick: () => void;
|
|
593
|
+
}): ReactNode {
|
|
594
|
+
return (
|
|
595
|
+
<div className="flex items-center gap-1">
|
|
596
|
+
<DropdownMenu>
|
|
597
|
+
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
598
|
+
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
|
599
|
+
<MoreVertical className="h-4 w-4" />
|
|
600
|
+
</Button>
|
|
601
|
+
</DropdownMenuTrigger>
|
|
602
|
+
<DropdownMenuContent align="end">
|
|
603
|
+
{onExecute && (
|
|
604
|
+
<DropdownMenuItem
|
|
605
|
+
onClick={(e) => {
|
|
606
|
+
e.stopPropagation();
|
|
607
|
+
void onExecute(row.uuid);
|
|
608
|
+
}}
|
|
609
|
+
>
|
|
610
|
+
<Play className="mr-2 h-4 w-4" />
|
|
611
|
+
Execute
|
|
612
|
+
</DropdownMenuItem>
|
|
613
|
+
)}
|
|
614
|
+
{onClone && (
|
|
615
|
+
<DropdownMenuItem
|
|
616
|
+
onClick={(e) => {
|
|
617
|
+
e.stopPropagation();
|
|
618
|
+
void onClone(row.uuid);
|
|
619
|
+
}}
|
|
620
|
+
>
|
|
621
|
+
<Copy className="mr-2 h-4 w-4" />
|
|
622
|
+
Duplicate
|
|
623
|
+
</DropdownMenuItem>
|
|
624
|
+
)}
|
|
625
|
+
{!row.isTemplate && (
|
|
626
|
+
<>
|
|
627
|
+
<DropdownMenuSeparator />
|
|
628
|
+
<DropdownMenuItem
|
|
629
|
+
className="text-destructive focus:text-destructive"
|
|
630
|
+
onClick={(e) => {
|
|
631
|
+
e.stopPropagation();
|
|
632
|
+
onDeleteClick();
|
|
633
|
+
}}
|
|
634
|
+
>
|
|
635
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
636
|
+
Delete
|
|
637
|
+
</DropdownMenuItem>
|
|
638
|
+
</>
|
|
639
|
+
)}
|
|
640
|
+
</DropdownMenuContent>
|
|
641
|
+
</DropdownMenu>
|
|
642
|
+
</div>
|
|
643
|
+
);
|
|
644
|
+
}
|