@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,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';
|