@startsimpli/ui 0.4.14 → 0.4.16

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 (78) 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/__tests__/team-settings-page.test.tsx +146 -0
  34. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  35. package/src/components/team/index.ts +62 -0
  36. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  37. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  38. package/src/components/team/members-table-default-class-names.ts +39 -0
  39. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  40. package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
  41. package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
  42. package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
  43. package/src/components/team/pages/index.ts +33 -0
  44. package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
  45. package/src/components/team/pages/types.ts +135 -0
  46. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  47. package/src/components/team/role-selector-default-class-names.ts +11 -0
  48. package/src/components/team/types.ts +97 -0
  49. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  50. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  51. package/src/components/workflows/NodeInspector.tsx +257 -0
  52. package/src/components/workflows/NodePalette.tsx +119 -0
  53. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  54. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  55. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  56. package/src/components/workflows/WorkflowNode.tsx +198 -0
  57. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  58. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  59. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  60. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  61. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  62. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  63. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  64. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  65. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  66. package/src/components/workflows/exec-status.ts +90 -0
  67. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  68. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  69. package/src/components/workflows/index.ts +78 -0
  70. package/src/components/workflows/layout/auto-layout.ts +142 -0
  71. package/src/components/workflows/node-icons.ts +31 -0
  72. package/src/components/workflows/serialization.ts +171 -0
  73. package/src/components/workflows/theme/categories.ts +96 -0
  74. package/src/components/workflows/types.ts +231 -0
  75. package/src/components/workflows/workflows.css +29 -0
  76. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  77. package/src/components/workspace/SplitPane.tsx +174 -0
  78. package/src/components/workspace/index.ts +4 -0
@@ -0,0 +1,138 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { WorkflowRunViewer } from '../WorkflowRunViewer';
3
+ import { ExecNodeDetails } from '../ExecNodeDetails';
4
+ import type { WfExecutionView, NodeExecView } from '../types';
5
+
6
+ // jsdom lacks matchMedia, which DualPaneWorkspace uses for its `auto` layout.
7
+ beforeAll(() => {
8
+ Object.defineProperty(window, 'matchMedia', {
9
+ writable: true,
10
+ value: (query: string) => ({
11
+ matches: false,
12
+ media: query,
13
+ onchange: null,
14
+ addEventListener: () => {},
15
+ removeEventListener: () => {},
16
+ addListener: () => {},
17
+ removeListener: () => {},
18
+ dispatchEvent: () => false,
19
+ }),
20
+ });
21
+ });
22
+
23
+ function makeExecution(overrides: Partial<WfExecutionView> = {}): WfExecutionView {
24
+ const nodeA: NodeExecView = {
25
+ nodeId: 'a',
26
+ name: 'Fetch data',
27
+ type: 'trigger.event',
28
+ status: 'success',
29
+ executionOrder: 0,
30
+ executionTimeMs: 1000,
31
+ inputData: { foo: 'in' },
32
+ outputData: { foo: 'out' },
33
+ };
34
+ const nodeB: NodeExecView = {
35
+ nodeId: 'b',
36
+ name: 'Transform',
37
+ type: 'transform.set',
38
+ status: 'running',
39
+ executionOrder: 1,
40
+ };
41
+ return {
42
+ executionId: 'exec-1',
43
+ status: 'running',
44
+ nodes: { a: nodeA, b: nodeB },
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ describe('WorkflowRunViewer', () => {
50
+ it('renders the run status header badge', () => {
51
+ render(<WorkflowRunViewer execution={makeExecution({ status: 'completed' })} />);
52
+ expect(screen.getByText('Completed')).toBeInTheDocument();
53
+ });
54
+
55
+ it('renders the timeline with a row per node, ordered by executionOrder', () => {
56
+ render(<WorkflowRunViewer execution={makeExecution()} />);
57
+ const rows = screen.getAllByRole('listitem');
58
+ expect(rows).toHaveLength(2);
59
+ expect(rows[0]).toHaveTextContent('Fetch data');
60
+ expect(rows[1]).toHaveTextContent('Transform');
61
+ });
62
+
63
+ it('shows selected-node details when a timeline row is selected', () => {
64
+ render(<WorkflowRunViewer execution={makeExecution()} />);
65
+ fireEvent.click(screen.getByText('Fetch data'));
66
+ // detail panel shows the selected node name as a heading + pretty JSON
67
+ expect(
68
+ screen.getByRole('heading', { name: 'Fetch data' })
69
+ ).toBeInTheDocument();
70
+ expect(screen.getByText(/"foo": "out"/)).toBeInTheDocument();
71
+ });
72
+
73
+ it('updates the selected-node details when a new snapshot is passed', () => {
74
+ const { rerender } = render(<WorkflowRunViewer execution={makeExecution()} />);
75
+ fireEvent.click(screen.getByText('Transform'));
76
+ // node b selected; detail panel headed by its name
77
+ expect(
78
+ screen.getByRole('heading', { name: 'Transform' })
79
+ ).toBeInTheDocument();
80
+
81
+ const next = makeExecution({ status: 'completed' });
82
+ next.nodes.b = {
83
+ ...next.nodes.b,
84
+ status: 'success',
85
+ executionTimeMs: 250,
86
+ outputData: { done: true },
87
+ };
88
+ rerender(<WorkflowRunViewer execution={next} />);
89
+ // detail panel reflects the new snapshot for the still-selected node b
90
+ expect(screen.getByText(/"done": true/)).toBeInTheDocument();
91
+ });
92
+
93
+ it('renders an empty state when there are no node executions', () => {
94
+ render(
95
+ <WorkflowRunViewer
96
+ execution={{ executionId: 'x', status: 'pending', nodes: {} }}
97
+ />
98
+ );
99
+ expect(screen.queryAllByRole('listitem')).toHaveLength(0);
100
+ expect(screen.getByText('Pending')).toBeInTheDocument();
101
+ });
102
+ });
103
+
104
+ describe('ExecNodeDetails', () => {
105
+ const node: NodeExecView = {
106
+ nodeId: 'a',
107
+ name: 'Fetch data',
108
+ type: 'trigger.event',
109
+ status: 'error',
110
+ executionOrder: 0,
111
+ error: 'boom',
112
+ inputData: { a: 1 },
113
+ outputData: { b: 2 },
114
+ };
115
+
116
+ it('renders node name, type and status', () => {
117
+ render(<ExecNodeDetails node={node} />);
118
+ expect(screen.getByText('Fetch data')).toBeInTheDocument();
119
+ expect(screen.getByText('trigger.event')).toBeInTheDocument();
120
+ expect(screen.getByText('Error')).toBeInTheDocument();
121
+ });
122
+
123
+ it('pretty-prints input and output JSON', () => {
124
+ render(<ExecNodeDetails node={node} />);
125
+ expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
126
+ expect(screen.getByText(/"b": 2/)).toBeInTheDocument();
127
+ });
128
+
129
+ it('renders error message when present', () => {
130
+ render(<ExecNodeDetails node={node} />);
131
+ expect(screen.getByText('boom')).toBeInTheDocument();
132
+ });
133
+
134
+ it('renders an empty state when no node is selected', () => {
135
+ render(<ExecNodeDetails node={null} />);
136
+ expect(screen.getByText(/select a node/i)).toBeInTheDocument();
137
+ });
138
+ });
@@ -0,0 +1,107 @@
1
+ import { autoLayout } from '../layout/auto-layout';
2
+ import { graphToFlow } from '../serialization';
3
+ import type { WorkflowGraph } from '../types';
4
+
5
+ function noOverlap(
6
+ nodes: { id: string; position: { x: number; y: number }; width?: number; height?: number }[]
7
+ ): boolean {
8
+ for (let i = 0; i < nodes.length; i++) {
9
+ for (let j = i + 1; j < nodes.length; j++) {
10
+ const a = nodes[i];
11
+ const b = nodes[j];
12
+ const aw = a.width ?? 180;
13
+ const ah = a.height ?? 60;
14
+ const bw = b.width ?? 180;
15
+ const bh = b.height ?? 60;
16
+ const overlapX = a.position.x < b.position.x + bw && a.position.x + aw > b.position.x;
17
+ const overlapY = a.position.y < b.position.y + bh && a.position.y + ah > b.position.y;
18
+ if (overlapX && overlapY) return false;
19
+ }
20
+ }
21
+ return true;
22
+ }
23
+
24
+ const chainGraph: WorkflowGraph = {
25
+ nodes: [
26
+ { id: 'a', name: 'A', type: 't' },
27
+ { id: 'b', name: 'B', type: 't' },
28
+ { id: 'c', name: 'C', type: 't' },
29
+ ],
30
+ edges: [
31
+ { source: 'a', target: 'b' },
32
+ { source: 'b', target: 'c' },
33
+ ],
34
+ };
35
+
36
+ describe('auto-layout', () => {
37
+ it('assigns non-overlapping positions (LR)', () => {
38
+ const { nodes, edges } = graphToFlow(chainGraph);
39
+ const laid = autoLayout(nodes, edges, { direction: 'LR' });
40
+ expect(laid.nodes).toHaveLength(3);
41
+ expect(noOverlap(laid.nodes)).toBe(true);
42
+ });
43
+
44
+ it('LR lays out left-to-right (x increases along the chain)', () => {
45
+ const { nodes, edges } = graphToFlow(chainGraph);
46
+ const laid = autoLayout(nodes, edges, { direction: 'LR' });
47
+ const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
48
+ expect(byId.b.position.x).toBeGreaterThan(byId.a.position.x);
49
+ expect(byId.c.position.x).toBeGreaterThan(byId.b.position.x);
50
+ });
51
+
52
+ it('TB lays out top-to-bottom (y increases along the chain)', () => {
53
+ const { nodes, edges } = graphToFlow(chainGraph);
54
+ const laid = autoLayout(nodes, edges, { direction: 'TB' });
55
+ const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
56
+ expect(byId.b.position.y).toBeGreaterThan(byId.a.position.y);
57
+ expect(byId.c.position.y).toBeGreaterThan(byId.b.position.y);
58
+ });
59
+
60
+ it('preserves explicit positions when preservePositions is set', () => {
61
+ const graph: WorkflowGraph = {
62
+ nodes: [
63
+ { id: 'a', name: 'A', type: 't', position: { x: 500, y: 500 } },
64
+ { id: 'b', name: 'B', type: 't' },
65
+ ],
66
+ edges: [{ source: 'a', target: 'b' }],
67
+ };
68
+ const { nodes, edges } = graphToFlow(graph);
69
+ const laid = autoLayout(nodes, edges, { direction: 'LR', preservePositions: true });
70
+ const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
71
+ expect(byId.a.position).toEqual({ x: 500, y: 500 });
72
+ // b still gets a computed position
73
+ expect(byId.b.position).toBeDefined();
74
+ });
75
+
76
+ it('places an edgeless / orphan node (still gets a position, no overlap)', () => {
77
+ const graph: WorkflowGraph = {
78
+ nodes: [
79
+ { id: 'a', name: 'A', type: 't' },
80
+ { id: 'b', name: 'B', type: 't' },
81
+ { id: 'orphan', name: 'O', type: 't' },
82
+ ],
83
+ edges: [{ source: 'a', target: 'b' }],
84
+ };
85
+ const { nodes, edges } = graphToFlow(graph);
86
+ const laid = autoLayout(nodes, edges, { direction: 'LR' });
87
+ const orphan = laid.nodes.find((n) => n.id === 'orphan');
88
+ expect(orphan).toBeDefined();
89
+ expect(orphan!.position).toBeDefined();
90
+ expect(Number.isFinite(orphan!.position.x)).toBe(true);
91
+ expect(Number.isFinite(orphan!.position.y)).toBe(true);
92
+ expect(noOverlap(laid.nodes)).toBe(true);
93
+ });
94
+
95
+ it('handles an empty node set', () => {
96
+ const laid = autoLayout([], [], { direction: 'TB' });
97
+ expect(laid.nodes).toEqual([]);
98
+ expect(laid.edges).toEqual([]);
99
+ });
100
+
101
+ it('defaults to TB when no direction is given', () => {
102
+ const { nodes, edges } = graphToFlow(chainGraph);
103
+ const laid = autoLayout(nodes, edges);
104
+ const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
105
+ expect(byId.c.position.y).toBeGreaterThan(byId.a.position.y);
106
+ });
107
+ });
@@ -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
+ }