@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.
- package/README.md +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- 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
|
+
});
|