@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,278 @@
1
+ import { graphToFlow, flowToGraph } from '../serialization';
2
+ import type { WorkflowGraph, BackendConnections } from '../types';
3
+
4
+ /**
5
+ * Build the LOCKED backend connections shape directly so we test against
6
+ * the exact format the Django test suite constructs:
7
+ *
8
+ * { sourceId: { "main": [[{ node, type, index }]] } }
9
+ */
10
+
11
+ describe('serialization — graphToFlow', () => {
12
+ it('converts a single-output edge to one flow edge', () => {
13
+ const graph: WorkflowGraph = {
14
+ nodes: [
15
+ { id: 'a', name: 'A', type: 'trigger.event' },
16
+ { id: 'b', name: 'B', type: 'transform.set' },
17
+ ],
18
+ edges: [{ source: 'a', target: 'b' }],
19
+ };
20
+ const { nodes, edges } = graphToFlow(graph);
21
+ expect(nodes).toHaveLength(2);
22
+ expect(edges).toHaveLength(1);
23
+ expect(edges[0]).toMatchObject({
24
+ source: 'a',
25
+ target: 'b',
26
+ sourceHandle: '0',
27
+ targetHandle: '0',
28
+ });
29
+ expect(edges[0].id).toBeTruthy();
30
+ // node data carries the original WfNode
31
+ expect(nodes[0].id).toBe('a');
32
+ expect(nodes[0].position).toBeDefined();
33
+ });
34
+
35
+ it('encodes multi-output (if/else) via distinct sourceHandles', () => {
36
+ const graph: WorkflowGraph = {
37
+ nodes: [
38
+ { id: 'cond', name: 'Check', type: 'condition.if' },
39
+ { id: 'pass', name: 'Pass', type: 'transform.set' },
40
+ { id: 'fail', name: 'Fail', type: 'transform.set' },
41
+ ],
42
+ edges: [
43
+ { source: 'cond', target: 'pass', sourceOutput: 0 },
44
+ { source: 'cond', target: 'fail', sourceOutput: 1 },
45
+ ],
46
+ };
47
+ const { edges } = graphToFlow(graph);
48
+ const passEdge = edges.find((e) => e.target === 'pass');
49
+ const failEdge = edges.find((e) => e.target === 'fail');
50
+ expect(passEdge?.sourceHandle).toBe('0');
51
+ expect(failEdge?.sourceHandle).toBe('1');
52
+ });
53
+
54
+ it('preserves explicit node positions', () => {
55
+ const graph: WorkflowGraph = {
56
+ nodes: [{ id: 'a', name: 'A', type: 't', position: { x: 42, y: 99 } }],
57
+ edges: [],
58
+ };
59
+ const { nodes } = graphToFlow(graph);
60
+ expect(nodes[0].position).toEqual({ x: 42, y: 99 });
61
+ });
62
+
63
+ it('handles an empty graph', () => {
64
+ const { nodes, edges } = graphToFlow({ nodes: [], edges: [] });
65
+ expect(nodes).toEqual([]);
66
+ expect(edges).toEqual([]);
67
+ });
68
+ });
69
+
70
+ describe('serialization — flowToGraph produces LOCKED backend shape', () => {
71
+ it('single-output edge', () => {
72
+ const graph: WorkflowGraph = {
73
+ nodes: [
74
+ { id: 'a', name: 'A', type: 'trigger.event' },
75
+ { id: 'b', name: 'B', type: 'transform.set' },
76
+ ],
77
+ edges: [{ source: 'a', target: 'b' }],
78
+ };
79
+ const { nodes, edges } = graphToFlow(graph);
80
+ const { connections } = flowToGraph(nodes, edges);
81
+ const expected: BackendConnections = {
82
+ a: { main: [[{ node: 'b', type: 'main', index: 0 }]] },
83
+ };
84
+ expect(connections).toEqual(expected);
85
+ });
86
+
87
+ it('multi-output if/else — output 0 and 1', () => {
88
+ const graph: WorkflowGraph = {
89
+ nodes: [
90
+ { id: 'cond', name: 'Check', type: 'condition.if' },
91
+ { id: 'pass', name: 'Pass', type: 'transform.set' },
92
+ { id: 'fail', name: 'Fail', type: 'transform.set' },
93
+ ],
94
+ edges: [
95
+ { source: 'cond', target: 'pass', sourceOutput: 0 },
96
+ { source: 'cond', target: 'fail', sourceOutput: 1 },
97
+ ],
98
+ };
99
+ const { nodes, edges } = graphToFlow(graph);
100
+ const { connections } = flowToGraph(nodes, edges);
101
+ expect(connections).toEqual({
102
+ cond: {
103
+ main: [
104
+ [{ node: 'pass', type: 'main', index: 0 }],
105
+ [{ node: 'fail', type: 'main', index: 0 }],
106
+ ],
107
+ },
108
+ });
109
+ });
110
+
111
+ it('parallel fan-out — one source output to multiple targets', () => {
112
+ const graph: WorkflowGraph = {
113
+ nodes: [
114
+ { id: 's', name: 'S', type: 'trigger.event' },
115
+ { id: 't1', name: 'T1', type: 'transform.set' },
116
+ { id: 't2', name: 'T2', type: 'transform.set' },
117
+ ],
118
+ edges: [
119
+ { source: 's', target: 't1' },
120
+ { source: 's', target: 't2' },
121
+ ],
122
+ };
123
+ const { nodes, edges } = graphToFlow(graph);
124
+ const { connections } = flowToGraph(nodes, edges);
125
+ expect(connections).toEqual({
126
+ s: {
127
+ main: [
128
+ [
129
+ { node: 't1', type: 'main', index: 0 },
130
+ { node: 't2', type: 'main', index: 0 },
131
+ ],
132
+ ],
133
+ },
134
+ });
135
+ });
136
+
137
+ it('empty graph yields empty connections', () => {
138
+ const { nodes, edges } = graphToFlow({ nodes: [], edges: [] });
139
+ const { connections } = flowToGraph(nodes, edges);
140
+ expect(connections).toEqual({});
141
+ });
142
+
143
+ it('dangling node with no edges produces no connection entry but is kept as a node', () => {
144
+ const graph: WorkflowGraph = {
145
+ nodes: [
146
+ { id: 'a', name: 'A', type: 'trigger.event' },
147
+ { id: 'orphan', name: 'Orphan', type: 'transform.set' },
148
+ { id: 'b', name: 'B', type: 'transform.set' },
149
+ ],
150
+ edges: [{ source: 'a', target: 'b' }],
151
+ };
152
+ const { nodes, edges } = graphToFlow(graph);
153
+ const result = flowToGraph(nodes, edges);
154
+ expect(result.connections).toEqual({
155
+ a: { main: [[{ node: 'b', type: 'main', index: 0 }]] },
156
+ });
157
+ expect(result.nodes.map((n) => n.id).sort()).toEqual(['a', 'b', 'orphan']);
158
+ });
159
+
160
+ it('respects a non-zero targetInput index', () => {
161
+ const graph: WorkflowGraph = {
162
+ nodes: [
163
+ { id: 'a', name: 'A', type: 'x' },
164
+ { id: 'm', name: 'Merge', type: 'x' },
165
+ ],
166
+ edges: [{ source: 'a', target: 'm', sourceOutput: 0, targetInput: 1 }],
167
+ };
168
+ const { nodes, edges } = graphToFlow(graph);
169
+ const { connections } = flowToGraph(nodes, edges);
170
+ expect(connections).toEqual({
171
+ a: { main: [[{ node: 'm', type: 'main', index: 1 }]] },
172
+ });
173
+ });
174
+
175
+ it('back-fills empty earlier outputs when only output 1 is connected', () => {
176
+ // n8n main is positional: output index 1 connected, index 0 empty.
177
+ const graph: WorkflowGraph = {
178
+ nodes: [
179
+ { id: 'cond', name: 'C', type: 'condition.if' },
180
+ { id: 'fail', name: 'F', type: 'x' },
181
+ ],
182
+ edges: [{ source: 'cond', target: 'fail', sourceOutput: 1 }],
183
+ };
184
+ const { nodes, edges } = graphToFlow(graph);
185
+ const { connections } = flowToGraph(nodes, edges);
186
+ expect(connections).toEqual({
187
+ cond: {
188
+ main: [[], [{ node: 'fail', type: 'main', index: 0 }]],
189
+ },
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('serialization — round-trip', () => {
195
+ const roundtrip = (graph: WorkflowGraph): BackendConnections => {
196
+ const { nodes, edges } = graphToFlow(graph);
197
+ return flowToGraph(nodes, edges).connections;
198
+ };
199
+
200
+ it('flowToGraph(graphToFlow(g)) connections equal direct conversion', () => {
201
+ const graph: WorkflowGraph = {
202
+ nodes: [
203
+ { id: 'trigger', name: 'Start', type: 'trigger.event' },
204
+ { id: 'transform', name: 'Set', type: 'transform.set' },
205
+ { id: 'condition', name: 'Check', type: 'condition.if' },
206
+ { id: 'pass_node', name: 'Pass', type: 'transform.set' },
207
+ { id: 'fail_node', name: 'Fail', type: 'transform.set' },
208
+ ],
209
+ edges: [
210
+ { source: 'trigger', target: 'transform' },
211
+ { source: 'transform', target: 'condition' },
212
+ { source: 'condition', target: 'pass_node', sourceOutput: 0 },
213
+ { source: 'condition', target: 'fail_node', sourceOutput: 1 },
214
+ ],
215
+ };
216
+ // This is the exact shape from the backend test_models.py fixture.
217
+ const expected: BackendConnections = {
218
+ trigger: { main: [[{ node: 'transform', type: 'main', index: 0 }]] },
219
+ transform: { main: [[{ node: 'condition', type: 'main', index: 0 }]] },
220
+ condition: {
221
+ main: [
222
+ [{ node: 'pass_node', type: 'main', index: 0 }],
223
+ [{ node: 'fail_node', type: 'main', index: 0 }],
224
+ ],
225
+ },
226
+ };
227
+ expect(roundtrip(graph)).toEqual(expected);
228
+ });
229
+
230
+ it('round-trips connections -> graph -> connections losslessly', () => {
231
+ const connections: BackendConnections = {
232
+ trigger: { main: [[{ node: 'transform', type: 'main', index: 0 }]] },
233
+ transform: { main: [[{ node: 'condition', type: 'main', index: 0 }]] },
234
+ condition: {
235
+ main: [
236
+ [{ node: 'pass_node', type: 'main', index: 0 }],
237
+ [{ node: 'fail_node', type: 'main', index: 0 }],
238
+ ],
239
+ },
240
+ };
241
+ const nodes = [
242
+ { id: 'trigger', name: 'Start', type: 'trigger.event' },
243
+ { id: 'transform', name: 'Set', type: 'transform.set' },
244
+ { id: 'condition', name: 'Check', type: 'condition.if' },
245
+ { id: 'pass_node', name: 'Pass', type: 'transform.set' },
246
+ { id: 'fail_node', name: 'Fail', type: 'transform.set' },
247
+ ];
248
+ const graph: WorkflowGraph = {
249
+ nodes,
250
+ edges: [],
251
+ };
252
+ // hydrate edges from backend connections then re-serialize
253
+ const hydrated = { ...graph, edges: connectionsToEdgesForTest(connections) };
254
+ expect(roundtrip(hydrated)).toEqual(connections);
255
+ });
256
+ });
257
+
258
+ // Helper mirroring how a consumer would hydrate edges from backend
259
+ // connections (kept local to the test to avoid coupling to impl detail).
260
+ function connectionsToEdgesForTest(connections: BackendConnections) {
261
+ const edges = [];
262
+ for (const [source, outputs] of Object.entries(connections)) {
263
+ for (const [type, outputLists] of Object.entries(outputs)) {
264
+ outputLists.forEach((conns, outputIndex) => {
265
+ for (const conn of conns) {
266
+ edges.push({
267
+ source,
268
+ target: conn.node,
269
+ sourceOutput: outputIndex,
270
+ targetInput: conn.index,
271
+ type,
272
+ });
273
+ }
274
+ });
275
+ }
276
+ }
277
+ return edges;
278
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared status presentation for the execution monitor: NodeStatus /
3
+ * RunStatus → StatusBadge config, plus a human duration formatter.
4
+ *
5
+ * Kept as a tiny pure module so both ExecutionTimeline and
6
+ * WorkflowRunViewer/ExecNodeDetails render statuses identically and the
7
+ * mapping stays test-covered in one place.
8
+ */
9
+ import type { StatusBadgeConfig } from '../badge/StatusBadge';
10
+ import type { NodeStatus, RunStatus } from './types';
11
+
12
+ /** NodeStatus → badge label + tailwind color classes. */
13
+ export const NODE_STATUS_BADGE: Record<NodeStatus, StatusBadgeConfig> = {
14
+ idle: {
15
+ label: 'Idle',
16
+ className: 'bg-gray-100 text-gray-600 border border-gray-200',
17
+ },
18
+ pending: {
19
+ label: 'Pending',
20
+ className: 'bg-amber-100 text-amber-800 border border-amber-200',
21
+ },
22
+ running: {
23
+ label: 'Running',
24
+ className: 'bg-blue-100 text-blue-800 border border-blue-200',
25
+ },
26
+ success: {
27
+ label: 'Success',
28
+ className: 'bg-green-100 text-green-800 border border-green-200',
29
+ },
30
+ error: {
31
+ label: 'Error',
32
+ className: 'bg-red-100 text-red-800 border border-red-200',
33
+ },
34
+ skipped: {
35
+ label: 'Skipped',
36
+ className: 'bg-gray-100 text-gray-500 border border-gray-200',
37
+ },
38
+ };
39
+
40
+ /** RunStatus → badge label + tailwind color classes. */
41
+ export const RUN_STATUS_BADGE: Record<RunStatus, StatusBadgeConfig> = {
42
+ pending: {
43
+ label: 'Pending',
44
+ className: 'bg-amber-100 text-amber-800 border border-amber-200',
45
+ },
46
+ running: {
47
+ label: 'Running',
48
+ className: 'bg-blue-100 text-blue-800 border border-blue-200',
49
+ },
50
+ completed: {
51
+ label: 'Completed',
52
+ className: 'bg-green-100 text-green-800 border border-green-200',
53
+ },
54
+ failed: {
55
+ label: 'Failed',
56
+ className: 'bg-red-100 text-red-800 border border-red-200',
57
+ },
58
+ cancelled: {
59
+ label: 'Cancelled',
60
+ className: 'bg-gray-100 text-gray-600 border border-gray-200',
61
+ },
62
+ waiting: {
63
+ label: 'Waiting',
64
+ className: 'bg-purple-100 text-purple-800 border border-purple-200',
65
+ },
66
+ };
67
+
68
+ /** Statuses that are still in flight (warrant a live indicator). */
69
+ const NON_TERMINAL: ReadonlySet<NodeStatus> = new Set(['running']);
70
+
71
+ export function isLiveStatus(status: NodeStatus): boolean {
72
+ return NON_TERMINAL.has(status);
73
+ }
74
+
75
+ /**
76
+ * Format a millisecond duration as a compact human string:
77
+ * `42ms`, `1.23s`, `1m 5s`. Returns `null` for nullish/negative input.
78
+ */
79
+ export function formatDuration(ms?: number | null): string | null {
80
+ if (ms == null || ms < 0 || Number.isNaN(ms)) return null;
81
+ if (ms < 1000) return `${Math.round(ms)}ms`;
82
+ const totalSeconds = ms / 1000;
83
+ if (totalSeconds < 60) {
84
+ // up to 2 decimal places, trimming trailing zeros
85
+ return `${parseFloat(totalSeconds.toFixed(2))}s`;
86
+ }
87
+ const minutes = Math.floor(totalSeconds / 60);
88
+ const seconds = Math.round(totalSeconds % 60);
89
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
90
+ }
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { graphToFlow, type FlowNode, type FlowEdge } from '../serialization';
5
+ import { autoLayout, type LayoutDirection } from '../layout/auto-layout';
6
+ import type { WorkflowGraph, NodeTypeDef } from '../types';
7
+ import type { WorkflowNodeData } from '../WorkflowNode';
8
+
9
+ export interface UseCanvasGraphOptions {
10
+ /** Node-type catalog used to enrich nodes with category + port metadata. */
11
+ nodeTypes?: NodeTypeDef[];
12
+ /** Layout direction for nodes without an explicit position. Default 'LR'. */
13
+ direction?: LayoutDirection;
14
+ /** Inline-rename callback wired onto every node's data. */
15
+ onRename?: (nodeId: string, name: string) => void;
16
+ }
17
+
18
+ /** Index a node-type catalog by slug for O(1) lookup. */
19
+ function indexNodeTypes(
20
+ nodeTypes: NodeTypeDef[] | undefined
21
+ ): Record<string, NodeTypeDef> {
22
+ const index: Record<string, NodeTypeDef> = {};
23
+ for (const nt of nodeTypes ?? []) index[nt.slug] = nt;
24
+ return index;
25
+ }
26
+
27
+ /**
28
+ * Derive laid-out xyflow nodes/edges from a {@link WorkflowGraph}, enriching
29
+ * each node with its category, output ports/labels (from the node-type
30
+ * catalog), and the rename callback. Auto-layout fills positions for nodes
31
+ * that don't carry an explicit one (explicit positions are preserved).
32
+ */
33
+ export function useCanvasGraph(
34
+ graph: WorkflowGraph,
35
+ options: UseCanvasGraphOptions = {}
36
+ ): { nodes: FlowNode[]; edges: FlowEdge[] } {
37
+ const { nodeTypes, direction = 'LR', onRename } = options;
38
+
39
+ return React.useMemo(() => {
40
+ const typeIndex = indexNodeTypes(nodeTypes);
41
+ const { nodes, edges } = graphToFlow(graph);
42
+
43
+ const enriched: FlowNode[] = nodes.map((n) => {
44
+ const wf = n.data.node;
45
+ const def = typeIndex[wf.type];
46
+ const category = def?.category ?? wf.type.split('.')[0];
47
+ const outputCount = def?.outputCount ?? 1;
48
+ const data: WorkflowNodeData = {
49
+ node: wf,
50
+ category,
51
+ outputCount,
52
+ outputLabels: def?.outputLabels,
53
+ onRename,
54
+ status: 'idle',
55
+ };
56
+ return { ...n, type: 'workflowNode', data };
57
+ });
58
+
59
+ const typedEdges: FlowEdge[] = edges.map((e) => ({
60
+ ...e,
61
+ type: 'workflowEdge',
62
+ }));
63
+
64
+ const laid = autoLayout(enriched, typedEdges, {
65
+ direction,
66
+ preservePositions: true,
67
+ });
68
+ return { nodes: laid.nodes, edges: typedEdges };
69
+ }, [graph, nodeTypes, direction, onRename]);
70
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { FlowNode, FlowEdge } from '../serialization';
5
+ import type { WfExecutionView, NodeStatus } from '../types';
6
+ import type { WorkflowNodeData } from '../WorkflowNode';
7
+ import type { WorkflowEdgeData } from '../WorkflowEdge';
8
+
9
+ /**
10
+ * Merge an optional {@link WfExecutionView} status overlay onto flow nodes and
11
+ * edges. Each node's `data.status` is set from the matching node execution
12
+ * (defaulting to "idle"); each edge's `data.sourceStatus` is set from its
13
+ * source node so {@link WorkflowEdge} can animate live runs.
14
+ *
15
+ * Returns fresh node/edge arrays (referentially stable when inputs are) so
16
+ * xyflow re-renders only on actual overlay changes — suitable for polling.
17
+ */
18
+ export function useNodeStatusOverlay(
19
+ nodes: FlowNode[],
20
+ edges: FlowEdge[],
21
+ execution?: WfExecutionView
22
+ ): { nodes: FlowNode[]; edges: FlowEdge[] } {
23
+ return React.useMemo(() => {
24
+ const statusFor = (nodeId: string): NodeStatus =>
25
+ execution?.nodes?.[nodeId]?.status ?? 'idle';
26
+
27
+ const overlaidNodes: FlowNode[] = nodes.map((n) => {
28
+ const status = statusFor(n.id);
29
+ const data = n.data as WorkflowNodeData;
30
+ if (data.status === status) return n;
31
+ return { ...n, data: { ...data, status } };
32
+ });
33
+
34
+ const overlaidEdges: FlowEdge[] = edges.map((e) => {
35
+ const sourceStatus = statusFor(e.source);
36
+ const data = (e.data ?? { connectionType: 'main' }) as FlowEdge['data'] &
37
+ WorkflowEdgeData;
38
+ if (data.sourceStatus === sourceStatus) return e;
39
+ return {
40
+ ...e,
41
+ data: { ...data, connectionType: data.connectionType ?? 'main', sourceStatus },
42
+ };
43
+ });
44
+
45
+ return { nodes: overlaidNodes, edges: overlaidEdges };
46
+ }, [nodes, edges, execution]);
47
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @startsimpli/ui/workflows — generic workflow-graph foundation.
3
+ *
4
+ * Pure-TS foundation (types, serialization, layout) for the workflow
5
+ * editor/viewer. Isolated behind a dedicated subpath so consumers that do
6
+ * not need @xyflow/react don't pull it in (mirrors gantt / email-editor).
7
+ *
8
+ * Also exports the read-only execution-monitor components (timeline + run
9
+ * viewer), which are plain React + shared @startsimpli/ui primitives.
10
+ */
11
+
12
+ // Types
13
+ export type {
14
+ NodeStatus,
15
+ RunStatus,
16
+ WfNode,
17
+ WfPosition,
18
+ WfEdge,
19
+ NodeTypeDef,
20
+ WorkflowGraph,
21
+ BackendConnection,
22
+ BackendConnections,
23
+ BackendNode,
24
+ NodeExecView,
25
+ WfExecutionView,
26
+ JsonSchema,
27
+ } from './types';
28
+
29
+ // Serialization (graph <-> xyflow <-> backend connections)
30
+ export {
31
+ graphToFlow,
32
+ flowToGraph,
33
+ connectionsToEdges,
34
+ edgeId,
35
+ } from './serialization';
36
+ export type { FlowNode, FlowEdge } from './serialization';
37
+
38
+ // Layout
39
+ export { autoLayout, layoutLR, layoutTB } from './layout/auto-layout';
40
+ export type { LayoutDirection, AutoLayoutOptions } from './layout/auto-layout';
41
+
42
+ // Theme
43
+ export {
44
+ CATEGORY_TOKENS,
45
+ DEFAULT_CATEGORY_TOKEN,
46
+ getCategoryToken,
47
+ } from './theme/categories';
48
+ export type { CategoryToken } from './theme/categories';
49
+
50
+ // Execution monitor (read-only run visualization)
51
+ export { ExecutionTimeline } from './ExecutionTimeline';
52
+ export type { ExecutionTimelineProps } from './ExecutionTimeline';
53
+ export { WorkflowRunViewer } from './WorkflowRunViewer';
54
+ export type { WorkflowRunViewerProps } from './WorkflowRunViewer';
55
+ export { ExecNodeDetails } from './ExecNodeDetails';
56
+ export type { ExecNodeDetailsProps } from './ExecNodeDetails';
57
+
58
+ // Visual editor (interactive xyflow canvas + palette + inspector)
59
+ export { WorkflowNode } from './WorkflowNode';
60
+ export type { WorkflowNodeData } from './WorkflowNode';
61
+ export { WorkflowEdge } from './WorkflowEdge';
62
+ export type { WorkflowEdgeData } from './WorkflowEdge';
63
+ export { WorkflowCanvas } from './WorkflowCanvas';
64
+ export type { WorkflowCanvasProps } from './WorkflowCanvas';
65
+ export { NodePalette, NODE_DND_MIME } from './NodePalette';
66
+ export type { NodePaletteProps } from './NodePalette';
67
+ export { NodeInspector } from './NodeInspector';
68
+ export type { NodeInspectorProps } from './NodeInspector';
69
+ export { WorkflowEditor } from './WorkflowEditor';
70
+ export type { WorkflowEditorProps } from './WorkflowEditor';
71
+
72
+ // Editor hooks
73
+ export { useCanvasGraph } from './hooks/useCanvasGraph';
74
+ export type { UseCanvasGraphOptions } from './hooks/useCanvasGraph';
75
+ export { useNodeStatusOverlay } from './hooks/useNodeStatusOverlay';
76
+
77
+ // Category icon resolver (lucide)
78
+ export { getCategoryIcon } from './node-icons';