@startsimpli/ui 0.4.14 → 0.4.15

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.
Files changed (71) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  34. package/src/components/team/index.ts +57 -0
  35. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  36. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  37. package/src/components/team/members-table-default-class-names.ts +39 -0
  38. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  39. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  40. package/src/components/team/role-selector-default-class-names.ts +11 -0
  41. package/src/components/team/types.ts +97 -0
  42. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  43. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  44. package/src/components/workflows/NodeInspector.tsx +257 -0
  45. package/src/components/workflows/NodePalette.tsx +119 -0
  46. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  47. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  48. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  49. package/src/components/workflows/WorkflowNode.tsx +198 -0
  50. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  51. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  52. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  53. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  54. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  55. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  56. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  57. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  58. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  59. package/src/components/workflows/exec-status.ts +90 -0
  60. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  61. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  62. package/src/components/workflows/index.ts +78 -0
  63. package/src/components/workflows/layout/auto-layout.ts +142 -0
  64. package/src/components/workflows/node-icons.ts +31 -0
  65. package/src/components/workflows/serialization.ts +171 -0
  66. package/src/components/workflows/theme/categories.ts +96 -0
  67. package/src/components/workflows/types.ts +231 -0
  68. package/src/components/workflows/workflows.css +29 -0
  69. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  70. package/src/components/workspace/SplitPane.tsx +174 -0
  71. package/src/components/workspace/index.ts +4 -0
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ ReactFlow,
6
+ ReactFlowProvider,
7
+ Background,
8
+ Controls,
9
+ type NodeTypes,
10
+ type EdgeTypes,
11
+ } from '@xyflow/react';
12
+ import { cn } from '../../lib/utils';
13
+ import { WorkflowNode } from './WorkflowNode';
14
+ import { WorkflowEdge } from './WorkflowEdge';
15
+ import { useCanvasGraph } from './hooks/useCanvasGraph';
16
+ import { useNodeStatusOverlay } from './hooks/useNodeStatusOverlay';
17
+ import type { WorkflowGraph, WfExecutionView, NodeTypeDef } from './types';
18
+ import type { LayoutDirection } from './layout/auto-layout';
19
+
20
+ const nodeTypes: NodeTypes = { workflowNode: WorkflowNode };
21
+ const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge };
22
+
23
+ export interface WorkflowCanvasProps {
24
+ /** The graph to render. */
25
+ graph: WorkflowGraph;
26
+ /** Node-type catalog (enriches nodes with category + ports). */
27
+ nodeTypes?: NodeTypeDef[];
28
+ /**
29
+ * Optional run overlay. When present, node status badges and edge
30
+ * animations reflect the live execution. Pass a fresh snapshot per poll.
31
+ */
32
+ execution?: WfExecutionView;
33
+ /** Layout direction for positionless nodes. Default 'LR'. */
34
+ direction?: LayoutDirection;
35
+ /** Inline-rename callback. */
36
+ onRename?: (nodeId: string, name: string) => void;
37
+ /** Fired when a node is selected on the canvas. */
38
+ onSelectNode?: (nodeId: string | null) => void;
39
+ /** Drop handler for palette drag-and-drop (slug + client coords). */
40
+ onDropNodeType?: (slug: string, clientX: number, clientY: number) => void;
41
+ className?: string;
42
+ }
43
+
44
+ function CanvasInner({
45
+ graph,
46
+ nodeTypes: catalog,
47
+ execution,
48
+ direction = 'LR',
49
+ onRename,
50
+ onSelectNode,
51
+ onDropNodeType,
52
+ className,
53
+ }: WorkflowCanvasProps) {
54
+ const { nodes: baseNodes, edges: baseEdges } = useCanvasGraph(graph, {
55
+ nodeTypes: catalog,
56
+ direction,
57
+ onRename,
58
+ });
59
+ const { nodes, edges } = useNodeStatusOverlay(baseNodes, baseEdges, execution);
60
+
61
+ const handleDragOver = React.useCallback((e: React.DragEvent) => {
62
+ if (!onDropNodeType) return;
63
+ e.preventDefault();
64
+ e.dataTransfer.dropEffect = 'move';
65
+ }, [onDropNodeType]);
66
+
67
+ const handleDrop = React.useCallback(
68
+ (e: React.DragEvent) => {
69
+ if (!onDropNodeType) return;
70
+ e.preventDefault();
71
+ const slug = e.dataTransfer.getData('application/x-workflow-node');
72
+ if (slug) onDropNodeType(slug, e.clientX, e.clientY);
73
+ },
74
+ [onDropNodeType]
75
+ );
76
+
77
+ return (
78
+ <div
79
+ className={cn('workflow-canvas h-full w-full', className)}
80
+ onDragOver={handleDragOver}
81
+ onDrop={handleDrop}
82
+ >
83
+ <ReactFlow
84
+ nodes={nodes as never}
85
+ edges={edges as never}
86
+ nodeTypes={nodeTypes}
87
+ edgeTypes={edgeTypes}
88
+ fitView
89
+ proOptions={{ hideAttribution: true }}
90
+ onNodeClick={(_, n) => onSelectNode?.(n.id)}
91
+ onPaneClick={() => onSelectNode?.(null)}
92
+ >
93
+ <Background gap={16} />
94
+ <Controls showInteractive={false} />
95
+ </ReactFlow>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Renders a {@link WorkflowGraph} on an interactive xyflow canvas with custom
102
+ * workflow nodes/edges, auto-layout, fit-view, and controls. When an
103
+ * `execution` overlay is supplied, node statuses + edge animations reflect the
104
+ * live run. Wraps its own {@link ReactFlowProvider} so it can be dropped in
105
+ * anywhere.
106
+ */
107
+ export function WorkflowCanvas(props: WorkflowCanvasProps) {
108
+ return (
109
+ <ReactFlowProvider>
110
+ <CanvasInner {...props} />
111
+ </ReactFlowProvider>
112
+ );
113
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ BaseEdge,
6
+ getBezierPath,
7
+ type EdgeProps,
8
+ } from '@xyflow/react';
9
+ import type { NodeStatus } from './types';
10
+
11
+ /**
12
+ * Data payload carried on an xyflow edge rendered by {@link WorkflowEdge}.
13
+ * `sourceStatus` is layered on by the canvas status overlay so the edge can
14
+ * animate while its source node is running (or has just succeeded).
15
+ */
16
+ export interface WorkflowEdgeData {
17
+ connectionType?: string;
18
+ /** Runtime status of the source node (drives the animated flow). */
19
+ sourceStatus?: NodeStatus;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ const ANIMATED: ReadonlySet<NodeStatus> = new Set(['running', 'success']);
24
+
25
+ /**
26
+ * Custom workflow edge: a bezier connector that animates a dashed flow when
27
+ * its source node is running or has succeeded (so live runs visibly "flow").
28
+ */
29
+ export function WorkflowEdge({
30
+ id,
31
+ sourceX,
32
+ sourceY,
33
+ targetX,
34
+ targetY,
35
+ sourcePosition,
36
+ targetPosition,
37
+ data,
38
+ markerEnd,
39
+ selected,
40
+ }: EdgeProps) {
41
+ const [edgePath] = getBezierPath({
42
+ sourceX,
43
+ sourceY,
44
+ targetX,
45
+ targetY,
46
+ sourcePosition,
47
+ targetPosition,
48
+ });
49
+
50
+ const d = data as WorkflowEdgeData | undefined;
51
+ const animated = d?.sourceStatus ? ANIMATED.has(d.sourceStatus) : false;
52
+
53
+ return (
54
+ <BaseEdge
55
+ id={id}
56
+ path={edgePath}
57
+ markerEnd={markerEnd}
58
+ className={animated ? 'workflow-edge--animated' : undefined}
59
+ style={{
60
+ strokeWidth: selected ? 2.5 : 1.5,
61
+ stroke: selected ? '#2563eb' : '#94a3b8',
62
+ }}
63
+ />
64
+ );
65
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace';
6
+ import { WorkflowCanvas } from './WorkflowCanvas';
7
+ import { NodePalette } from './NodePalette';
8
+ import { NodeInspector } from './NodeInspector';
9
+ import type { NodeTypeDef, WfNode, WorkflowGraph } from './types';
10
+
11
+ export interface WorkflowEditorProps {
12
+ /** The graph being edited (controlled when `onGraphChange` is provided). */
13
+ graph: WorkflowGraph;
14
+ /** Node-type catalog for the palette + canvas enrichment + inspector. */
15
+ nodeTypes: NodeTypeDef[];
16
+ /** Emitted with the next graph whenever the editor mutates it. */
17
+ onGraphChange?: (graph: WorkflowGraph) => void;
18
+ className?: string;
19
+ }
20
+
21
+ function nextNodeId(graph: WorkflowGraph): string {
22
+ let i = graph.nodes.length + 1;
23
+ const ids = new Set(graph.nodes.map((n) => n.id));
24
+ while (ids.has(`node-${i}`)) i += 1;
25
+ return `node-${i}`;
26
+ }
27
+
28
+ /**
29
+ * Visual workflow editor: a {@link NodePalette} + interactive
30
+ * {@link WorkflowCanvas} on the left, a JSON-Schema-driven
31
+ * {@link NodeInspector} on the right (via {@link DualPaneWorkspace}). Manages
32
+ * selection internally and surfaces graph mutations (rename, param edits,
33
+ * palette add) through `onGraphChange`.
34
+ */
35
+ export function WorkflowEditor({
36
+ graph,
37
+ nodeTypes,
38
+ onGraphChange,
39
+ className,
40
+ }: WorkflowEditorProps) {
41
+ const [selectedId, setSelectedId] = React.useState<string | null>(null);
42
+
43
+ const typeIndex = React.useMemo(() => {
44
+ const idx: Record<string, NodeTypeDef> = {};
45
+ for (const nt of nodeTypes) idx[nt.slug] = nt;
46
+ return idx;
47
+ }, [nodeTypes]);
48
+
49
+ const selectedNode = selectedId
50
+ ? graph.nodes.find((n) => n.id === selectedId) ?? null
51
+ : null;
52
+ const selectedType = selectedNode ? typeIndex[selectedNode.type] : undefined;
53
+
54
+ const handleRename = React.useCallback(
55
+ (nodeId: string, name: string) => {
56
+ onGraphChange?.({
57
+ ...graph,
58
+ nodes: graph.nodes.map((n) => (n.id === nodeId ? { ...n, name } : n)),
59
+ });
60
+ },
61
+ [graph, onGraphChange]
62
+ );
63
+
64
+ const handleParametersChange = React.useCallback(
65
+ (nodeId: string, parameters: Record<string, unknown>) => {
66
+ onGraphChange?.({
67
+ ...graph,
68
+ nodes: graph.nodes.map((n) =>
69
+ n.id === nodeId ? { ...n, parameters } : n
70
+ ),
71
+ });
72
+ },
73
+ [graph, onGraphChange]
74
+ );
75
+
76
+ const handleAddNode = React.useCallback(
77
+ (slug: string) => {
78
+ const def = typeIndex[slug];
79
+ const id = nextNodeId(graph);
80
+ const node: WfNode = {
81
+ id,
82
+ name: def?.name ?? slug,
83
+ type: slug,
84
+ parameters: {},
85
+ };
86
+ onGraphChange?.({ ...graph, nodes: [...graph.nodes, node] });
87
+ setSelectedId(id);
88
+ },
89
+ [graph, onGraphChange, typeIndex]
90
+ );
91
+
92
+ const left = (
93
+ <DualPaneWorkspace
94
+ leftLabel="Nodes"
95
+ initialLeftSize={30}
96
+ minLeftSize={18}
97
+ maxLeftSize={45}
98
+ left={<NodePalette nodeTypes={nodeTypes} onAddNode={handleAddNode} />}
99
+ right={
100
+ <WorkflowCanvas
101
+ graph={graph}
102
+ nodeTypes={nodeTypes}
103
+ onRename={handleRename}
104
+ onSelectNode={setSelectedId}
105
+ onDropNodeType={(slug) => handleAddNode(slug)}
106
+ />
107
+ }
108
+ />
109
+ );
110
+
111
+ return (
112
+ <div className={cn('h-full min-h-0 w-full', className)}>
113
+ <DualPaneWorkspace
114
+ leftLabel="Editor"
115
+ rightLabel="Inspector"
116
+ initialLeftSize={68}
117
+ minLeftSize={45}
118
+ maxLeftSize={82}
119
+ left={left}
120
+ right={
121
+ <NodeInspector
122
+ node={selectedNode}
123
+ nodeType={selectedType}
124
+ onParametersChange={handleParametersChange}
125
+ />
126
+ }
127
+ />
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,198 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Handle, Position, type NodeProps } from '@xyflow/react';
5
+ import { cn } from '../../lib/utils';
6
+ import { StatusBadge } from '../badge/StatusBadge';
7
+ import { getCategoryToken } from './theme/categories';
8
+ import { getCategoryIcon } from './node-icons';
9
+ import { NODE_STATUS_BADGE } from './exec-status';
10
+ import type { NodeStatus, WfNode } from './types';
11
+
12
+ /**
13
+ * Data payload carried on an xyflow node rendered by {@link WorkflowNode}.
14
+ * The serializer stores the canonical {@link WfNode} under `node`; the canvas
15
+ * layers presentation extras (category, status overlay, port labels, rename
16
+ * callback) on top.
17
+ */
18
+ export interface WorkflowNodeData {
19
+ /** Canonical graph node. */
20
+ node: WfNode;
21
+ /** Category token key (resolved via theme/categories). */
22
+ category?: string;
23
+ /** Runtime status overlay (defaults to "idle" in the editor). */
24
+ status?: NodeStatus;
25
+ /** Number of inputs (default 1). */
26
+ inputCount?: number;
27
+ /** Number of outputs (default 1). */
28
+ outputCount?: number;
29
+ /** Labels for each output port (e.g. ["true", "false"]). */
30
+ outputLabels?: string[];
31
+ /** Labels for each input port. */
32
+ inputLabels?: string[];
33
+ /** Called when the node is renamed inline. */
34
+ onRename?: (nodeId: string, name: string) => void;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /** Build evenly-spaced top offsets (as %) for `count` handles. */
39
+ function handleOffsets(count: number): string[] {
40
+ if (count <= 1) return ['50%'];
41
+ return Array.from({ length: count }, (_, i) =>
42
+ `${((i + 1) / (count + 1)) * 100}%`
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Custom xyflow node for the workflow editor: category accent color + icon,
48
+ * an editable title, labelled input/output ports (handles), a runtime status
49
+ * badge, and disabled styling. Renaming is inline (double-click the title)
50
+ * and surfaced via `data.onRename`.
51
+ */
52
+ export function WorkflowNode({ id, data, selected }: NodeProps) {
53
+ const d = data as WorkflowNodeData;
54
+ const node = d.node;
55
+ const token = getCategoryToken(d.category ?? deriveCategory(node.type));
56
+ const Icon = getCategoryIcon(token.icon);
57
+ const status: NodeStatus = d.status ?? 'idle';
58
+ const disabled = Boolean(node.disabled);
59
+
60
+ const inputCount = d.inputCount ?? 1;
61
+ const outputCount = d.outputCount ?? 1;
62
+ const inputOffsets = handleOffsets(inputCount);
63
+ const outputOffsets = handleOffsets(outputCount);
64
+
65
+ const [editing, setEditing] = React.useState(false);
66
+ const [draft, setDraft] = React.useState(node.name);
67
+ React.useEffect(() => {
68
+ setDraft(node.name);
69
+ }, [node.name]);
70
+
71
+ const commit = () => {
72
+ setEditing(false);
73
+ const next = draft.trim();
74
+ if (next && next !== node.name) {
75
+ d.onRename?.(id, next);
76
+ } else {
77
+ setDraft(node.name);
78
+ }
79
+ };
80
+
81
+ return (
82
+ <div
83
+ data-node-id={id}
84
+ data-status={status}
85
+ data-disabled={disabled ? 'true' : undefined}
86
+ className={cn(
87
+ 'relative min-w-[180px] rounded-lg border-2 bg-card shadow-sm transition-shadow',
88
+ selected && 'ring-2 ring-offset-1',
89
+ disabled && 'opacity-50 grayscale'
90
+ )}
91
+ style={{
92
+ borderColor: token.color,
93
+ // selection ring picks up the category accent
94
+ ['--tw-ring-color' as string]: token.color,
95
+ }}
96
+ >
97
+ {/* Input handles */}
98
+ {inputOffsets.map((top, i) => (
99
+ <React.Fragment key={`in-${i}`}>
100
+ <Handle
101
+ id={String(i)}
102
+ type="target"
103
+ position={Position.Left}
104
+ data-handle-kind="input"
105
+ style={{ top, background: token.color }}
106
+ />
107
+ {d.inputLabels?.[i] && (
108
+ <span
109
+ className="absolute left-2 -translate-y-1/2 text-[10px] text-muted-foreground"
110
+ style={{ top }}
111
+ >
112
+ {d.inputLabels[i]}
113
+ </span>
114
+ )}
115
+ </React.Fragment>
116
+ ))}
117
+
118
+ {/* Header: icon + category label */}
119
+ <div
120
+ className="flex items-center gap-1.5 rounded-t-md px-2.5 py-1"
121
+ style={{ background: token.background, color: token.foreground }}
122
+ >
123
+ <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
124
+ <span className="text-[11px] font-medium uppercase tracking-wide">
125
+ {token.label}
126
+ </span>
127
+ <StatusBadge
128
+ status={status}
129
+ config={NODE_STATUS_BADGE}
130
+ size="sm"
131
+ className="ml-auto"
132
+ />
133
+ </div>
134
+
135
+ {/* Title (inline-editable) */}
136
+ <div className="px-2.5 py-2">
137
+ {editing ? (
138
+ <input
139
+ autoFocus
140
+ role="textbox"
141
+ aria-label="Node name"
142
+ value={draft}
143
+ onChange={(e) => setDraft(e.target.value)}
144
+ onBlur={commit}
145
+ onKeyDown={(e) => {
146
+ if (e.key === 'Enter') commit();
147
+ if (e.key === 'Escape') {
148
+ setDraft(node.name);
149
+ setEditing(false);
150
+ }
151
+ }}
152
+ className="w-full rounded border border-border bg-background px-1 py-0.5 text-sm font-medium text-foreground outline-none"
153
+ />
154
+ ) : (
155
+ <button
156
+ type="button"
157
+ onDoubleClick={() => d.onRename && setEditing(true)}
158
+ className="block w-full truncate text-left text-sm font-medium text-foreground"
159
+ title={node.name}
160
+ >
161
+ {node.name}
162
+ </button>
163
+ )}
164
+ <span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
165
+ {node.type}
166
+ </span>
167
+ </div>
168
+
169
+ {/* Output handles + labels */}
170
+ {outputOffsets.map((top, i) => (
171
+ <React.Fragment key={`out-${i}`}>
172
+ <Handle
173
+ id={String(i)}
174
+ type="source"
175
+ position={Position.Right}
176
+ data-handle-kind="output"
177
+ style={{ top, background: token.color }}
178
+ />
179
+ {d.outputLabels?.[i] && (
180
+ <span
181
+ className="absolute right-2 -translate-y-1/2 text-[10px] text-muted-foreground"
182
+ style={{ top }}
183
+ >
184
+ {d.outputLabels[i]}
185
+ </span>
186
+ )}
187
+ </React.Fragment>
188
+ ))}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ /** Best-effort category from a "category.subtype" slug (e.g. "action.email"). */
194
+ function deriveCategory(type: string | undefined): string | undefined {
195
+ if (!type) return undefined;
196
+ const head = type.split('.')[0];
197
+ return head || undefined;
198
+ }
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { StatusBadge } from '../badge/StatusBadge';
6
+ import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace';
7
+ import { ExecutionTimeline } from './ExecutionTimeline';
8
+ import { ExecNodeDetails } from './ExecNodeDetails';
9
+ import { RUN_STATUS_BADGE } from './exec-status';
10
+ import type { NodeExecView, WfExecutionView } from './types';
11
+
12
+ export interface WorkflowRunViewerProps {
13
+ /** The current run snapshot. Pass a fresh object on each poll. */
14
+ execution: WfExecutionView;
15
+ /** Initially-selected node id. */
16
+ initialNodeId?: string;
17
+ className?: string;
18
+ }
19
+
20
+ function nodeList(execution: WfExecutionView): NodeExecView[] {
21
+ return Object.values(execution.nodes ?? {});
22
+ }
23
+
24
+ /**
25
+ * Read-only run visualizer: an execution timeline beside a selected-node
26
+ * detail panel (built on {@link DualPaneWorkspace}), with a run-status
27
+ * header badge. Designed for polling — pass a fresh {@link WfExecutionView}
28
+ * snapshot on each tick and the timeline + currently-selected detail panel
29
+ * re-render against the latest data.
30
+ */
31
+ export function WorkflowRunViewer({
32
+ execution,
33
+ initialNodeId,
34
+ className,
35
+ }: WorkflowRunViewerProps) {
36
+ const nodes = nodeList(execution);
37
+ const [selectedId, setSelectedId] = React.useState<string | null>(
38
+ initialNodeId ?? null
39
+ );
40
+
41
+ // Resolve the selected node against the LATEST snapshot so the detail
42
+ // panel reflects new poll data without needing to re-select.
43
+ const selectedNode = selectedId != null ? execution.nodes?.[selectedId] : null;
44
+
45
+ const header = (
46
+ <div className="flex items-center justify-between gap-3 px-3 py-2">
47
+ <div className="flex items-center gap-2">
48
+ <span className="text-sm font-semibold text-foreground">
49
+ Workflow run
50
+ </span>
51
+ {execution.executionId && (
52
+ <span className="text-xs text-muted-foreground">
53
+ {execution.executionId}
54
+ </span>
55
+ )}
56
+ </div>
57
+ <StatusBadge status={execution.status} config={RUN_STATUS_BADGE} size="sm" />
58
+ </div>
59
+ );
60
+
61
+ return (
62
+ <div className={cn('flex h-full min-h-0 w-full flex-col', className)}>
63
+ <DualPaneWorkspace
64
+ toolbar={header}
65
+ leftLabel="Timeline"
66
+ rightLabel="Node detail"
67
+ initialLeftSize={42}
68
+ left={
69
+ <div className="h-full min-h-0 overflow-auto">
70
+ <ExecutionTimeline
71
+ nodeExecutions={nodes}
72
+ activeNodeId={selectedId ?? undefined}
73
+ onSelectNode={setSelectedId}
74
+ />
75
+ </div>
76
+ }
77
+ right={<ExecNodeDetails node={selectedNode} />}
78
+ />
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,99 @@
1
+ import { render, screen, fireEvent, within } from '@testing-library/react';
2
+ import { ExecutionTimeline } from '../ExecutionTimeline';
3
+ import type { NodeExecView } from '../types';
4
+
5
+ const nodes: NodeExecView[] = [
6
+ {
7
+ nodeId: 'a',
8
+ name: 'Fetch data',
9
+ type: 'trigger.event',
10
+ status: 'success',
11
+ executionOrder: 0,
12
+ executionTimeMs: 1234,
13
+ },
14
+ {
15
+ nodeId: 'b',
16
+ name: 'Transform',
17
+ type: 'transform.set',
18
+ status: 'running',
19
+ executionOrder: 1,
20
+ },
21
+ {
22
+ nodeId: 'c',
23
+ name: 'Notify',
24
+ type: 'action.http',
25
+ status: 'error',
26
+ executionOrder: 2,
27
+ executionTimeMs: 42,
28
+ error: 'Connection refused',
29
+ },
30
+ ];
31
+
32
+ describe('ExecutionTimeline', () => {
33
+ it('renders one row per node execution, in order', () => {
34
+ render(<ExecutionTimeline nodeExecutions={nodes} />);
35
+ const rows = screen.getAllByRole('listitem');
36
+ expect(rows).toHaveLength(3);
37
+ expect(rows[0]).toHaveTextContent('Fetch data');
38
+ expect(rows[1]).toHaveTextContent('Transform');
39
+ expect(rows[2]).toHaveTextContent('Notify');
40
+ });
41
+
42
+ it('renders a status badge per node with the correct label', () => {
43
+ render(<ExecutionTimeline nodeExecutions={nodes} />);
44
+ expect(screen.getByText('Success')).toBeInTheDocument();
45
+ expect(screen.getByText('Running')).toBeInTheDocument();
46
+ expect(screen.getByText('Error')).toBeInTheDocument();
47
+ });
48
+
49
+ it('renders human-readable durations', () => {
50
+ render(<ExecutionTimeline nodeExecutions={nodes} />);
51
+ expect(screen.getByText('1.23s')).toBeInTheDocument();
52
+ expect(screen.getByText('42ms')).toBeInTheDocument();
53
+ });
54
+
55
+ it('renders inline error text for error nodes', () => {
56
+ render(<ExecutionTimeline nodeExecutions={nodes} />);
57
+ expect(screen.getByText('Connection refused')).toBeInTheDocument();
58
+ });
59
+
60
+ it('shows a live/pulsing indicator only for the running node', () => {
61
+ const { container } = render(<ExecutionTimeline nodeExecutions={nodes} />);
62
+ const live = container.querySelectorAll('[data-live="true"]');
63
+ expect(live).toHaveLength(1);
64
+ const rows = screen.getAllByRole('listitem');
65
+ // running node is the second row (b)
66
+ expect(within(rows[1]).getByText('Running')).toBeInTheDocument();
67
+ expect(rows[1].querySelector('[data-live="true"]')).not.toBeNull();
68
+ });
69
+
70
+ it('does not show a live indicator for terminal statuses', () => {
71
+ const terminal: NodeExecView[] = [
72
+ { nodeId: 'a', name: 'A', type: 't', status: 'success', executionOrder: 0 },
73
+ { nodeId: 'b', name: 'B', type: 't', status: 'error', executionOrder: 1 },
74
+ { nodeId: 'c', name: 'C', type: 't', status: 'skipped', executionOrder: 2 },
75
+ ];
76
+ const { container } = render(<ExecutionTimeline nodeExecutions={terminal} />);
77
+ expect(container.querySelectorAll('[data-live="true"]')).toHaveLength(0);
78
+ });
79
+
80
+ it('calls onSelectNode when a row is clicked', () => {
81
+ const onSelectNode = jest.fn();
82
+ render(<ExecutionTimeline nodeExecutions={nodes} onSelectNode={onSelectNode} />);
83
+ fireEvent.click(screen.getByText('Transform'));
84
+ expect(onSelectNode).toHaveBeenCalledWith('b');
85
+ });
86
+
87
+ it('marks the active node row as selected', () => {
88
+ render(<ExecutionTimeline nodeExecutions={nodes} activeNodeId="c" />);
89
+ const rows = screen.getAllByRole('listitem');
90
+ expect(rows[2]).toHaveAttribute('aria-current', 'true');
91
+ expect(rows[0]).not.toHaveAttribute('aria-current', 'true');
92
+ });
93
+
94
+ it('renders an empty state when there are no executions', () => {
95
+ render(<ExecutionTimeline nodeExecutions={[]} />);
96
+ expect(screen.queryAllByRole('listitem')).toHaveLength(0);
97
+ expect(screen.getByText('No node executions')).toBeInTheDocument();
98
+ });
99
+ });