@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.22",
3
+ "version": "0.4.23",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
+ }