@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.
- 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/__tests__/team-settings-page.test.tsx +146 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +62 -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/pages/DomainsSettingsPage.tsx +289 -0
- package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
- package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
- package/src/components/team/pages/index.ts +33 -0
- package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
- package/src/components/team/pages/types.ts +135 -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,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';
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dagre-based auto-layout for workflow flow graphs.
|
|
3
|
+
*
|
|
4
|
+
* Wraps dagre with LR (left-to-right) and TB (top-to-bottom) presets,
|
|
5
|
+
* assigning non-overlapping positions to xyflow nodes. Nodes with explicit
|
|
6
|
+
* positions can be preserved (`preservePositions`), and edgeless / orphan
|
|
7
|
+
* nodes are laid out by dagre alongside connected ones (dagre places
|
|
8
|
+
* disconnected components without overlap).
|
|
9
|
+
*
|
|
10
|
+
* Pure-TS — no React. Operates on the structural FlowNode/FlowEdge shapes
|
|
11
|
+
* from serialization.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import dagre from 'dagre';
|
|
15
|
+
|
|
16
|
+
import type { FlowNode, FlowEdge } from '../serialization';
|
|
17
|
+
|
|
18
|
+
export type LayoutDirection = 'LR' | 'TB';
|
|
19
|
+
|
|
20
|
+
export interface AutoLayoutOptions {
|
|
21
|
+
/** Layout direction. Defaults to 'TB'. */
|
|
22
|
+
direction?: LayoutDirection;
|
|
23
|
+
/** Default node width (px) for layout sizing. */
|
|
24
|
+
nodeWidth?: number;
|
|
25
|
+
/** Default node height (px) for layout sizing. */
|
|
26
|
+
nodeHeight?: number;
|
|
27
|
+
/** Horizontal spacing between ranks/nodes (px). */
|
|
28
|
+
rankSep?: number;
|
|
29
|
+
/** Spacing between nodes in the same rank (px). */
|
|
30
|
+
nodeSep?: number;
|
|
31
|
+
/**
|
|
32
|
+
* When true, nodes whose source data already has an explicit position
|
|
33
|
+
* keep it; only positionless nodes are laid out by dagre.
|
|
34
|
+
*/
|
|
35
|
+
preservePositions?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_NODE_WIDTH = 180;
|
|
39
|
+
const DEFAULT_NODE_HEIGHT = 60;
|
|
40
|
+
const DEFAULT_RANK_SEP = 80;
|
|
41
|
+
const DEFAULT_NODE_SEP = 50;
|
|
42
|
+
|
|
43
|
+
/** Whether this flow node carries an explicit (user-set) position. */
|
|
44
|
+
function hasExplicitPosition(node: FlowNode): boolean {
|
|
45
|
+
return Boolean(node.data?.node?.position);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute non-overlapping positions for the given nodes/edges.
|
|
50
|
+
*
|
|
51
|
+
* Returns new node objects with updated `position`; edges are returned
|
|
52
|
+
* unchanged. The original arrays are not mutated.
|
|
53
|
+
*/
|
|
54
|
+
export function autoLayout(
|
|
55
|
+
nodes: FlowNode[],
|
|
56
|
+
edges: FlowEdge[],
|
|
57
|
+
options: AutoLayoutOptions = {}
|
|
58
|
+
): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
|
59
|
+
const {
|
|
60
|
+
direction = 'TB',
|
|
61
|
+
nodeWidth = DEFAULT_NODE_WIDTH,
|
|
62
|
+
nodeHeight = DEFAULT_NODE_HEIGHT,
|
|
63
|
+
rankSep = DEFAULT_RANK_SEP,
|
|
64
|
+
nodeSep = DEFAULT_NODE_SEP,
|
|
65
|
+
preservePositions = false,
|
|
66
|
+
} = options;
|
|
67
|
+
|
|
68
|
+
if (nodes.length === 0) {
|
|
69
|
+
return { nodes: [], edges };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const g = new dagre.graphlib.Graph();
|
|
73
|
+
g.setGraph({
|
|
74
|
+
rankdir: direction,
|
|
75
|
+
ranksep: rankSep,
|
|
76
|
+
nodesep: nodeSep,
|
|
77
|
+
marginx: 20,
|
|
78
|
+
marginy: 20,
|
|
79
|
+
});
|
|
80
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
81
|
+
|
|
82
|
+
const pinned = new Set<string>();
|
|
83
|
+
|
|
84
|
+
for (const node of nodes) {
|
|
85
|
+
const w = node.width ?? nodeWidth;
|
|
86
|
+
const h = node.height ?? nodeHeight;
|
|
87
|
+
g.setNode(node.id, { width: w, height: h });
|
|
88
|
+
if (preservePositions && hasExplicitPosition(node)) {
|
|
89
|
+
pinned.add(node.id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const edge of edges) {
|
|
94
|
+
// Only add edges whose endpoints are present (defensive).
|
|
95
|
+
if (g.hasNode(edge.source) && g.hasNode(edge.target)) {
|
|
96
|
+
g.setEdge(edge.source, edge.target);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
dagre.layout(g);
|
|
101
|
+
|
|
102
|
+
const laidNodes: FlowNode[] = nodes.map((node) => {
|
|
103
|
+
if (pinned.has(node.id)) {
|
|
104
|
+
// Keep the explicit position verbatim.
|
|
105
|
+
return {
|
|
106
|
+
...node,
|
|
107
|
+
position: { ...(node.data.node.position as { x: number; y: number }) },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const pos = g.node(node.id);
|
|
111
|
+
const w = node.width ?? nodeWidth;
|
|
112
|
+
const h = node.height ?? nodeHeight;
|
|
113
|
+
// dagre returns center coords; convert to top-left for xyflow.
|
|
114
|
+
return {
|
|
115
|
+
...node,
|
|
116
|
+
position: {
|
|
117
|
+
x: (pos?.x ?? 0) - w / 2,
|
|
118
|
+
y: (pos?.y ?? 0) - h / 2,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { nodes: laidNodes, edges };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Convenience: left-to-right layout. */
|
|
127
|
+
export function layoutLR(
|
|
128
|
+
nodes: FlowNode[],
|
|
129
|
+
edges: FlowEdge[],
|
|
130
|
+
options: Omit<AutoLayoutOptions, 'direction'> = {}
|
|
131
|
+
) {
|
|
132
|
+
return autoLayout(nodes, edges, { ...options, direction: 'LR' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Convenience: top-to-bottom layout. */
|
|
136
|
+
export function layoutTB(
|
|
137
|
+
nodes: FlowNode[],
|
|
138
|
+
edges: FlowEdge[],
|
|
139
|
+
options: Omit<AutoLayoutOptions, 'direction'> = {}
|
|
140
|
+
) {
|
|
141
|
+
return autoLayout(nodes, edges, { ...options, direction: 'TB' });
|
|
142
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a category token's icon hint (a lucide-react icon name) to the actual
|
|
3
|
+
* lucide component. Kept separate from theme/categories.ts so that module
|
|
4
|
+
* stays pure-TS (no React/lucide import) while the editor can still resolve a
|
|
5
|
+
* real icon component. Unknown names fall back to `Box`.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
Box,
|
|
9
|
+
GitBranch,
|
|
10
|
+
Play,
|
|
11
|
+
Settings2,
|
|
12
|
+
Shuffle,
|
|
13
|
+
Workflow,
|
|
14
|
+
Zap,
|
|
15
|
+
type LucideIcon,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
|
|
18
|
+
const ICONS: Record<string, LucideIcon> = {
|
|
19
|
+
Box,
|
|
20
|
+
GitBranch,
|
|
21
|
+
Play,
|
|
22
|
+
Settings2,
|
|
23
|
+
Shuffle,
|
|
24
|
+
Workflow,
|
|
25
|
+
Zap,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function getCategoryIcon(iconName: string | undefined): LucideIcon {
|
|
29
|
+
if (!iconName) return Box;
|
|
30
|
+
return ICONS[iconName] ?? Box;
|
|
31
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow graph <-> xyflow conversion.
|
|
3
|
+
*
|
|
4
|
+
* `graphToFlow` turns a {@link WorkflowGraph} (flat nodes + edges) into the
|
|
5
|
+
* xyflow `{ nodes, edges }` shape an editor renders.
|
|
6
|
+
*
|
|
7
|
+
* `flowToGraph` turns xyflow nodes/edges back into `{ nodes, connections }`
|
|
8
|
+
* where `connections` is the LOCKED backend n8n shape:
|
|
9
|
+
*
|
|
10
|
+
* ```json
|
|
11
|
+
* { "sourceId": { "main": [[{ "node": "targetId", "type": "main", "index": 0 }]] } }
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Round-trip guarantee: `flowToGraph(graphToFlow(g)).connections` equals the
|
|
15
|
+
* direct serialization of `g`'s edges into that shape. The xyflow
|
|
16
|
+
* `sourceHandle` encodes the source output index and `targetHandle` encodes
|
|
17
|
+
* the target input index, so multi-output (if/else), parallel fan-out, and
|
|
18
|
+
* non-zero target inputs all survive the round-trip.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
WorkflowGraph,
|
|
23
|
+
WfNode,
|
|
24
|
+
WfEdge,
|
|
25
|
+
BackendConnection,
|
|
26
|
+
BackendConnections,
|
|
27
|
+
} from './types';
|
|
28
|
+
|
|
29
|
+
// ── xyflow shapes (structurally typed; we only depend on the fields used) ──
|
|
30
|
+
|
|
31
|
+
/** Minimal xyflow Node shape produced/consumed here. */
|
|
32
|
+
export interface FlowNode {
|
|
33
|
+
id: string;
|
|
34
|
+
type?: string;
|
|
35
|
+
position: { x: number; y: number };
|
|
36
|
+
data: { node: WfNode } & Record<string, unknown>;
|
|
37
|
+
width?: number;
|
|
38
|
+
height?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal xyflow Edge shape produced/consumed here. */
|
|
42
|
+
export interface FlowEdge {
|
|
43
|
+
id: string;
|
|
44
|
+
source: string;
|
|
45
|
+
target: string;
|
|
46
|
+
sourceHandle?: string | null;
|
|
47
|
+
targetHandle?: string | null;
|
|
48
|
+
type?: string;
|
|
49
|
+
data?: { connectionType: string } & Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_CONNECTION_TYPE = 'main';
|
|
53
|
+
|
|
54
|
+
/** Stable edge id encoding source/output -> target/input. */
|
|
55
|
+
export function edgeId(
|
|
56
|
+
source: string,
|
|
57
|
+
sourceOutput: number,
|
|
58
|
+
target: string,
|
|
59
|
+
targetInput: number
|
|
60
|
+
): string {
|
|
61
|
+
return `${source}:${sourceOutput}->${target}:${targetInput}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convert a {@link WorkflowGraph} into xyflow `{ nodes, edges }`.
|
|
66
|
+
*
|
|
67
|
+
* Node positions: explicit `node.position` is preserved; otherwise a
|
|
68
|
+
* placeholder `{ x: 0, y: 0 }` is assigned (run auto-layout for real
|
|
69
|
+
* coordinates).
|
|
70
|
+
*/
|
|
71
|
+
export function graphToFlow(graph: WorkflowGraph): {
|
|
72
|
+
nodes: FlowNode[];
|
|
73
|
+
edges: FlowEdge[];
|
|
74
|
+
} {
|
|
75
|
+
const nodes: FlowNode[] = graph.nodes.map((node) => ({
|
|
76
|
+
id: node.id,
|
|
77
|
+
type: node.type,
|
|
78
|
+
position: node.position ?? { x: 0, y: 0 },
|
|
79
|
+
data: { node },
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const edges: FlowEdge[] = graph.edges.map((edge) => {
|
|
83
|
+
const sourceOutput = edge.sourceOutput ?? 0;
|
|
84
|
+
const targetInput = edge.targetInput ?? 0;
|
|
85
|
+
const connectionType = edge.type ?? DEFAULT_CONNECTION_TYPE;
|
|
86
|
+
return {
|
|
87
|
+
id: edgeId(edge.source, sourceOutput, edge.target, targetInput),
|
|
88
|
+
source: edge.source,
|
|
89
|
+
target: edge.target,
|
|
90
|
+
sourceHandle: String(sourceOutput),
|
|
91
|
+
targetHandle: String(targetInput),
|
|
92
|
+
type: 'default',
|
|
93
|
+
data: { connectionType },
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return { nodes, edges };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse a handle id back to its numeric index (null/undefined -> 0). */
|
|
101
|
+
function handleIndex(handle: string | null | undefined): number {
|
|
102
|
+
if (handle === null || handle === undefined || handle === '') return 0;
|
|
103
|
+
const n = Number.parseInt(handle, 10);
|
|
104
|
+
return Number.isNaN(n) ? 0 : n;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convert xyflow nodes/edges back into `{ nodes, connections }`.
|
|
109
|
+
*
|
|
110
|
+
* `connections` is the LOCKED backend n8n shape. The `main` (or other
|
|
111
|
+
* connection type) array is positional by source output index — earlier
|
|
112
|
+
* unconnected outputs are back-filled with empty arrays so a connection on
|
|
113
|
+
* output index 1 yields `[[], [...]]`.
|
|
114
|
+
*/
|
|
115
|
+
export function flowToGraph(
|
|
116
|
+
nodes: FlowNode[],
|
|
117
|
+
edges: FlowEdge[]
|
|
118
|
+
): { nodes: WfNode[]; connections: BackendConnections } {
|
|
119
|
+
const outNodes: WfNode[] = nodes.map((n) => n.data.node);
|
|
120
|
+
|
|
121
|
+
const connections: BackendConnections = {};
|
|
122
|
+
|
|
123
|
+
for (const edge of edges) {
|
|
124
|
+
const connectionType =
|
|
125
|
+
edge.data?.connectionType ?? edge.type ?? DEFAULT_CONNECTION_TYPE;
|
|
126
|
+
const sourceOutput = handleIndex(edge.sourceHandle);
|
|
127
|
+
const targetInput = handleIndex(edge.targetHandle);
|
|
128
|
+
|
|
129
|
+
const sourceMap = (connections[edge.source] ??= {});
|
|
130
|
+
const outputLists = (sourceMap[connectionType] ??= []);
|
|
131
|
+
|
|
132
|
+
// Back-fill positional output slots up to sourceOutput.
|
|
133
|
+
while (outputLists.length <= sourceOutput) {
|
|
134
|
+
outputLists.push([]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const conn: BackendConnection = {
|
|
138
|
+
node: edge.target,
|
|
139
|
+
type: connectionType,
|
|
140
|
+
index: targetInput,
|
|
141
|
+
};
|
|
142
|
+
outputLists[sourceOutput].push(conn);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { nodes: outNodes, connections };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Hydrate flat {@link WfEdge}s from the LOCKED backend connections shape.
|
|
150
|
+
* Inverse of the connections half of {@link flowToGraph}; useful when
|
|
151
|
+
* loading a workflow from the API into editor state.
|
|
152
|
+
*/
|
|
153
|
+
export function connectionsToEdges(connections: BackendConnections): WfEdge[] {
|
|
154
|
+
const edges: WfEdge[] = [];
|
|
155
|
+
for (const [source, outputs] of Object.entries(connections)) {
|
|
156
|
+
for (const [type, outputLists] of Object.entries(outputs)) {
|
|
157
|
+
outputLists.forEach((conns, sourceOutput) => {
|
|
158
|
+
for (const conn of conns) {
|
|
159
|
+
edges.push({
|
|
160
|
+
source,
|
|
161
|
+
target: conn.node,
|
|
162
|
+
sourceOutput,
|
|
163
|
+
targetInput: conn.index,
|
|
164
|
+
type,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return edges;
|
|
171
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic category → visual token map.
|
|
3
|
+
*
|
|
4
|
+
* Maps a node-type category (free string, e.g. the backend
|
|
5
|
+
* NodeType.Category choices) to a small token bundle the renderer can use.
|
|
6
|
+
* Tokens are framework-agnostic (CSS custom-property friendly hex + a
|
|
7
|
+
* Tailwind-ish class hint) so this stays in pure-TS land — no React here.
|
|
8
|
+
*
|
|
9
|
+
* Categories mirror the backend NodeType.Category text choices but the
|
|
10
|
+
* map is open: unknown categories resolve to DEFAULT_CATEGORY_TOKEN.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface CategoryToken {
|
|
14
|
+
/** Stable category key. */
|
|
15
|
+
category: string;
|
|
16
|
+
/** Human label. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Accent color (hex) for borders/handles. */
|
|
19
|
+
color: string;
|
|
20
|
+
/** Background tint (hex). */
|
|
21
|
+
background: string;
|
|
22
|
+
/** Foreground/text color (hex). */
|
|
23
|
+
foreground: string;
|
|
24
|
+
/** Icon hint (lucide-react icon name) — consumed by a later React epic. */
|
|
25
|
+
icon: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Fallback token for unknown categories. */
|
|
29
|
+
export const DEFAULT_CATEGORY_TOKEN: CategoryToken = {
|
|
30
|
+
category: 'default',
|
|
31
|
+
label: 'Node',
|
|
32
|
+
color: '#64748b', // slate-500
|
|
33
|
+
background: '#f1f5f9', // slate-100
|
|
34
|
+
foreground: '#0f172a', // slate-900
|
|
35
|
+
icon: 'Box',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Built-in category tokens. Keys match backend NodeType.Category values.
|
|
40
|
+
*/
|
|
41
|
+
export const CATEGORY_TOKENS: Record<string, CategoryToken> = {
|
|
42
|
+
trigger: {
|
|
43
|
+
category: 'trigger',
|
|
44
|
+
label: 'Trigger',
|
|
45
|
+
color: '#16a34a', // green-600
|
|
46
|
+
background: '#dcfce7', // green-100
|
|
47
|
+
foreground: '#14532d', // green-900
|
|
48
|
+
icon: 'Zap',
|
|
49
|
+
},
|
|
50
|
+
action: {
|
|
51
|
+
category: 'action',
|
|
52
|
+
label: 'Action',
|
|
53
|
+
color: '#2563eb', // blue-600
|
|
54
|
+
background: '#dbeafe', // blue-100
|
|
55
|
+
foreground: '#1e3a8a', // blue-900
|
|
56
|
+
icon: 'Play',
|
|
57
|
+
},
|
|
58
|
+
condition: {
|
|
59
|
+
category: 'condition',
|
|
60
|
+
label: 'Condition',
|
|
61
|
+
color: '#d97706', // amber-600
|
|
62
|
+
background: '#fef3c7', // amber-100
|
|
63
|
+
foreground: '#78350f', // amber-900
|
|
64
|
+
icon: 'GitBranch',
|
|
65
|
+
},
|
|
66
|
+
transform: {
|
|
67
|
+
category: 'transform',
|
|
68
|
+
label: 'Transform',
|
|
69
|
+
color: '#7c3aed', // violet-600
|
|
70
|
+
background: '#ede9fe', // violet-100
|
|
71
|
+
foreground: '#4c1d95', // violet-900
|
|
72
|
+
icon: 'Shuffle',
|
|
73
|
+
},
|
|
74
|
+
subworkflow: {
|
|
75
|
+
category: 'subworkflow',
|
|
76
|
+
label: 'Sub-workflow',
|
|
77
|
+
color: '#0891b2', // cyan-600
|
|
78
|
+
background: '#cffafe', // cyan-100
|
|
79
|
+
foreground: '#164e63', // cyan-900
|
|
80
|
+
icon: 'Workflow',
|
|
81
|
+
},
|
|
82
|
+
control: {
|
|
83
|
+
category: 'control',
|
|
84
|
+
label: 'Control',
|
|
85
|
+
color: '#db2777', // pink-600
|
|
86
|
+
background: '#fce7f3', // pink-100
|
|
87
|
+
foreground: '#831843', // pink-900
|
|
88
|
+
icon: 'Settings2',
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Resolve a category token, falling back to the default. */
|
|
93
|
+
export function getCategoryToken(category: string | undefined): CategoryToken {
|
|
94
|
+
if (!category) return DEFAULT_CATEGORY_TOKEN;
|
|
95
|
+
return CATEGORY_TOKENS[category] ?? DEFAULT_CATEGORY_TOKEN;
|
|
96
|
+
}
|