@startsimpli/ui 0.4.27 → 0.4.29
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/package.json +2 -2
- package/src/components/index.ts +1 -0
- package/src/components/navigation/app-shell.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +95 -4
- package/src/components/workflows/WorkflowEditor.tsx +37 -1
- package/src/components/workflows/__tests__/graph-ops.test.ts +101 -0
- package/src/components/workflows/graph-ops.ts +89 -0
- package/src/components/workflows/index.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.29",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
33
33
|
"react": "^18.0.0 || ^19.0.0",
|
|
34
34
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
35
|
-
"@startsimpli/auth": "0.4.
|
|
35
|
+
"@startsimpli/auth": "0.4.25"
|
|
36
36
|
},
|
|
37
37
|
"peerDependenciesMeta": {
|
|
38
38
|
"@startsimpli/auth": {
|
package/src/components/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ export * from './unified-table/utils'
|
|
|
52
52
|
|
|
53
53
|
// Export Navigation
|
|
54
54
|
export * from './navigation/sidebar'
|
|
55
|
+
export * from './navigation/app-shell'
|
|
55
56
|
|
|
56
57
|
// Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
|
|
57
58
|
export * from './workspace'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, type ReactNode } from 'react';
|
|
4
|
+
import { useAuth, buildCentralAuthUrl } from '@startsimpli/auth';
|
|
5
|
+
import { LogOut, User } from 'lucide-react';
|
|
6
|
+
import { Sidebar, SidebarLayout, type SidebarSection } from './sidebar';
|
|
7
|
+
import { Button } from '../ui/button';
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuLabel,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
} from '../ui/dropdown-menu';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The one shared authenticated app shell — Sidebar SaaS chrome for EVERY app.
|
|
19
|
+
*
|
|
20
|
+
* Previously each app hand-rolled the same assembly in its own layout: the
|
|
21
|
+
* central-auth gate (bounce to sign-in when there's no session), the loading
|
|
22
|
+
* state, the account menu (email + Log out), and composing
|
|
23
|
+
* <Sidebar> + <SidebarLayout>. That's the duplication — it now lives here once.
|
|
24
|
+
* An app supplies only its slug + brand + nav sections:
|
|
25
|
+
*
|
|
26
|
+
* <AppShell app="market" appName="Market Simpli" sections={NAV}>{children}</AppShell>
|
|
27
|
+
*
|
|
28
|
+
* `useAuth` / `buildCentralAuthUrl` come from the @startsimpli/auth peer dep, so
|
|
29
|
+
* no app wires auth itself. The visual is the shared <Sidebar> (dark, collapsible),
|
|
30
|
+
* identical across apps.
|
|
31
|
+
*/
|
|
32
|
+
export interface AppShellProps {
|
|
33
|
+
/** App slug for the central-auth return flow, e.g. 'market' | 'foundry'. */
|
|
34
|
+
app: string;
|
|
35
|
+
/** Brand / product name shown at the top of the sidebar. */
|
|
36
|
+
appName: string;
|
|
37
|
+
/** Sidebar navigation sections (label + href + optional icon). */
|
|
38
|
+
sections: SidebarSection[];
|
|
39
|
+
/** Extra items rendered above "Log out" in the account menu (optional). */
|
|
40
|
+
accountMenuExtra?: ReactNode;
|
|
41
|
+
/** Loading label while the session bootstraps (default "Loading…"). */
|
|
42
|
+
loadingLabel?: string;
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function AppShell({
|
|
47
|
+
app,
|
|
48
|
+
appName,
|
|
49
|
+
sections,
|
|
50
|
+
accountMenuExtra,
|
|
51
|
+
loadingLabel = 'Loading…',
|
|
52
|
+
children,
|
|
53
|
+
}: AppShellProps) {
|
|
54
|
+
const { user, isLoading, logout } = useAuth();
|
|
55
|
+
|
|
56
|
+
const signin = (returnTo: string) => buildCentralAuthUrl('signin', { app, returnTo });
|
|
57
|
+
|
|
58
|
+
// Gate: no session once bootstrap settles -> bounce to central auth,
|
|
59
|
+
// preserving the current URL so the user returns here post-signin.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (isLoading || user) return;
|
|
62
|
+
if (typeof window === 'undefined') return;
|
|
63
|
+
window.location.href = signin(window.location.href);
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [user, isLoading, app]);
|
|
66
|
+
|
|
67
|
+
const handleLogout = async () => {
|
|
68
|
+
try {
|
|
69
|
+
await logout();
|
|
70
|
+
} finally {
|
|
71
|
+
if (typeof window !== 'undefined') {
|
|
72
|
+
window.location.href = signin(`${window.location.origin}/`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (isLoading || !user) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
|
|
80
|
+
{loadingLabel}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const account = user.email || user.name || 'Account';
|
|
86
|
+
|
|
87
|
+
const userDropdown = (
|
|
88
|
+
<DropdownMenu>
|
|
89
|
+
<DropdownMenuTrigger asChild>
|
|
90
|
+
<Button
|
|
91
|
+
variant="ghost"
|
|
92
|
+
size="sm"
|
|
93
|
+
className="w-full justify-start gap-2 text-gray-300 hover:bg-gray-800 hover:text-white"
|
|
94
|
+
>
|
|
95
|
+
<User className="h-4 w-4 flex-shrink-0" />
|
|
96
|
+
<span className="truncate text-sm">{account}</span>
|
|
97
|
+
</Button>
|
|
98
|
+
</DropdownMenuTrigger>
|
|
99
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
100
|
+
<DropdownMenuLabel className="truncate">{account}</DropdownMenuLabel>
|
|
101
|
+
<DropdownMenuSeparator />
|
|
102
|
+
{accountMenuExtra}
|
|
103
|
+
<DropdownMenuItem
|
|
104
|
+
className="cursor-pointer text-red-600 focus:text-red-600"
|
|
105
|
+
onClick={handleLogout}
|
|
106
|
+
>
|
|
107
|
+
<LogOut className="mr-2 h-4 w-4" /> Log out
|
|
108
|
+
</DropdownMenuItem>
|
|
109
|
+
</DropdownMenuContent>
|
|
110
|
+
</DropdownMenu>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<>
|
|
115
|
+
<Sidebar appName={appName} sections={sections} userDropdown={userDropdown} collapsible />
|
|
116
|
+
<SidebarLayout>{children}</SidebarLayout>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -8,13 +8,15 @@ import {
|
|
|
8
8
|
Controls,
|
|
9
9
|
type NodeTypes,
|
|
10
10
|
type EdgeTypes,
|
|
11
|
+
type Connection,
|
|
11
12
|
} from '@xyflow/react';
|
|
12
13
|
import { cn } from '../../lib/utils';
|
|
13
14
|
import { WorkflowNode } from './WorkflowNode';
|
|
14
15
|
import { WorkflowEdge } from './WorkflowEdge';
|
|
15
16
|
import { useCanvasGraph } from './hooks/useCanvasGraph';
|
|
16
17
|
import { useNodeStatusOverlay } from './hooks/useNodeStatusOverlay';
|
|
17
|
-
import
|
|
18
|
+
import { connectionToEdge } from './graph-ops';
|
|
19
|
+
import type { WorkflowGraph, WfEdge, WfExecutionView, NodeTypeDef } from './types';
|
|
18
20
|
import type { LayoutDirection } from './layout/auto-layout';
|
|
19
21
|
|
|
20
22
|
const nodeTypes: NodeTypes = { workflowNode: WorkflowNode };
|
|
@@ -36,8 +38,23 @@ export interface WorkflowCanvasProps {
|
|
|
36
38
|
onRename?: (nodeId: string, name: string) => void;
|
|
37
39
|
/** Fired when a node is selected on the canvas. */
|
|
38
40
|
onSelectNode?: (nodeId: string | null) => void;
|
|
41
|
+
/** Fired when an edge is selected on the canvas. */
|
|
42
|
+
onSelectEdge?: (edgeId: string | null) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Currently-selected node id. Threaded onto xyflow's `selected` flag so the
|
|
45
|
+
* controlled canvas knows what's selected — required for keyboard delete.
|
|
46
|
+
*/
|
|
47
|
+
selectedNodeId?: string | null;
|
|
48
|
+
/** Currently-selected edge id (same reason as {@link selectedNodeId}). */
|
|
49
|
+
selectedEdgeId?: string | null;
|
|
39
50
|
/** Drop handler for palette drag-and-drop (slug + client coords). */
|
|
40
51
|
onDropNodeType?: (slug: string, clientX: number, clientY: number) => void;
|
|
52
|
+
/** Fired when the user draws a connection between two node handles. */
|
|
53
|
+
onConnectEdge?: (edge: WfEdge) => void;
|
|
54
|
+
/** Fired with the ids of nodes removed via select+Delete/Backspace. */
|
|
55
|
+
onDeleteNodes?: (nodeIds: string[]) => void;
|
|
56
|
+
/** Fired with the edges removed via select+Delete/Backspace. */
|
|
57
|
+
onDeleteEdges?: (edges: WfEdge[]) => void;
|
|
41
58
|
className?: string;
|
|
42
59
|
}
|
|
43
60
|
|
|
@@ -48,7 +65,13 @@ function CanvasInner({
|
|
|
48
65
|
direction = 'LR',
|
|
49
66
|
onRename,
|
|
50
67
|
onSelectNode,
|
|
68
|
+
onSelectEdge,
|
|
69
|
+
selectedNodeId,
|
|
70
|
+
selectedEdgeId,
|
|
51
71
|
onDropNodeType,
|
|
72
|
+
onConnectEdge,
|
|
73
|
+
onDeleteNodes,
|
|
74
|
+
onDeleteEdges,
|
|
52
75
|
className,
|
|
53
76
|
}: WorkflowCanvasProps) {
|
|
54
77
|
const { nodes: baseNodes, edges: baseEdges } = useCanvasGraph(graph, {
|
|
@@ -56,7 +79,32 @@ function CanvasInner({
|
|
|
56
79
|
direction,
|
|
57
80
|
onRename,
|
|
58
81
|
});
|
|
59
|
-
const { nodes, edges } = useNodeStatusOverlay(
|
|
82
|
+
const { nodes: overlaidNodes, edges: overlaidEdges } = useNodeStatusOverlay(
|
|
83
|
+
baseNodes,
|
|
84
|
+
baseEdges,
|
|
85
|
+
execution
|
|
86
|
+
);
|
|
87
|
+
// Thread our selection into xyflow's `selected` flag. The canvas is fully
|
|
88
|
+
// controlled (no onNodesChange), so without this xyflow never marks a
|
|
89
|
+
// clicked node selected and keyboard delete has nothing to remove.
|
|
90
|
+
const nodes = React.useMemo(
|
|
91
|
+
() =>
|
|
92
|
+
selectedNodeId == null
|
|
93
|
+
? overlaidNodes
|
|
94
|
+
: overlaidNodes.map((n) =>
|
|
95
|
+
n.id === selectedNodeId ? { ...n, selected: true } : n
|
|
96
|
+
),
|
|
97
|
+
[overlaidNodes, selectedNodeId]
|
|
98
|
+
);
|
|
99
|
+
const edges = React.useMemo(
|
|
100
|
+
() =>
|
|
101
|
+
selectedEdgeId == null
|
|
102
|
+
? overlaidEdges
|
|
103
|
+
: overlaidEdges.map((e) =>
|
|
104
|
+
e.id === selectedEdgeId ? { ...e, selected: true } : e
|
|
105
|
+
),
|
|
106
|
+
[overlaidEdges, selectedEdgeId]
|
|
107
|
+
);
|
|
60
108
|
|
|
61
109
|
const handleDragOver = React.useCallback((e: React.DragEvent) => {
|
|
62
110
|
if (!onDropNodeType) return;
|
|
@@ -74,6 +122,17 @@ function CanvasInner({
|
|
|
74
122
|
[onDropNodeType]
|
|
75
123
|
);
|
|
76
124
|
|
|
125
|
+
const handleConnect = React.useCallback(
|
|
126
|
+
(conn: Connection) => {
|
|
127
|
+
if (!onConnectEdge || !conn.source || !conn.target) return;
|
|
128
|
+
onConnectEdge(connectionToEdge(conn));
|
|
129
|
+
},
|
|
130
|
+
[onConnectEdge]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Only enable keyboard delete when a delete handler is wired.
|
|
134
|
+
const deletable = !!onDeleteNodes || !!onDeleteEdges;
|
|
135
|
+
|
|
77
136
|
return (
|
|
78
137
|
<div
|
|
79
138
|
className={cn('workflow-canvas h-full w-full', className)}
|
|
@@ -87,8 +146,40 @@ function CanvasInner({
|
|
|
87
146
|
edgeTypes={edgeTypes}
|
|
88
147
|
fitView
|
|
89
148
|
proOptions={{ hideAttribution: true }}
|
|
90
|
-
|
|
91
|
-
|
|
149
|
+
deleteKeyCode={deletable ? ['Backspace', 'Delete'] : null}
|
|
150
|
+
onConnect={onConnectEdge ? handleConnect : undefined}
|
|
151
|
+
onNodesDelete={
|
|
152
|
+
onDeleteNodes
|
|
153
|
+
? (removed) => onDeleteNodes(removed.map((n) => n.id))
|
|
154
|
+
: undefined
|
|
155
|
+
}
|
|
156
|
+
onEdgesDelete={
|
|
157
|
+
onDeleteEdges
|
|
158
|
+
? (removed) =>
|
|
159
|
+
onDeleteEdges(
|
|
160
|
+
removed.map((e) =>
|
|
161
|
+
connectionToEdge({
|
|
162
|
+
source: e.source,
|
|
163
|
+
target: e.target,
|
|
164
|
+
sourceHandle: e.sourceHandle,
|
|
165
|
+
targetHandle: e.targetHandle,
|
|
166
|
+
})
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
: undefined
|
|
170
|
+
}
|
|
171
|
+
onNodeClick={(_, n) => {
|
|
172
|
+
onSelectNode?.(n.id);
|
|
173
|
+
onSelectEdge?.(null);
|
|
174
|
+
}}
|
|
175
|
+
onEdgeClick={(_, e) => {
|
|
176
|
+
onSelectEdge?.(e.id);
|
|
177
|
+
onSelectNode?.(null);
|
|
178
|
+
}}
|
|
179
|
+
onPaneClick={() => {
|
|
180
|
+
onSelectNode?.(null);
|
|
181
|
+
onSelectEdge?.(null);
|
|
182
|
+
}}
|
|
92
183
|
>
|
|
93
184
|
<Background gap={16} />
|
|
94
185
|
<Controls showInteractive={false} />
|
|
@@ -6,7 +6,12 @@ import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace';
|
|
|
6
6
|
import { WorkflowCanvas } from './WorkflowCanvas';
|
|
7
7
|
import { NodePalette } from './NodePalette';
|
|
8
8
|
import { NodeInspector } from './NodeInspector';
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
addEdgeToGraph,
|
|
11
|
+
removeEdgesFromGraph,
|
|
12
|
+
removeNodesFromGraph,
|
|
13
|
+
} from './graph-ops';
|
|
14
|
+
import type { NodeTypeDef, WfEdge, WfNode, WorkflowGraph } from './types';
|
|
10
15
|
|
|
11
16
|
export interface WorkflowEditorProps {
|
|
12
17
|
/** The graph being edited (controlled when `onGraphChange` is provided). */
|
|
@@ -39,6 +44,9 @@ export function WorkflowEditor({
|
|
|
39
44
|
className,
|
|
40
45
|
}: WorkflowEditorProps) {
|
|
41
46
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
|
47
|
+
const [selectedEdgeId, setSelectedEdgeId] = React.useState<string | null>(
|
|
48
|
+
null
|
|
49
|
+
);
|
|
42
50
|
|
|
43
51
|
const typeIndex = React.useMemo(() => {
|
|
44
52
|
const idx: Record<string, NodeTypeDef> = {};
|
|
@@ -89,6 +97,28 @@ export function WorkflowEditor({
|
|
|
89
97
|
[graph, onGraphChange, typeIndex]
|
|
90
98
|
);
|
|
91
99
|
|
|
100
|
+
const handleConnectEdge = React.useCallback(
|
|
101
|
+
(edge: WfEdge) => {
|
|
102
|
+
onGraphChange?.(addEdgeToGraph(graph, edge));
|
|
103
|
+
},
|
|
104
|
+
[graph, onGraphChange]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handleDeleteNodes = React.useCallback(
|
|
108
|
+
(nodeIds: string[]) => {
|
|
109
|
+
onGraphChange?.(removeNodesFromGraph(graph, nodeIds));
|
|
110
|
+
if (selectedId && nodeIds.includes(selectedId)) setSelectedId(null);
|
|
111
|
+
},
|
|
112
|
+
[graph, onGraphChange, selectedId]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const handleDeleteEdges = React.useCallback(
|
|
116
|
+
(edges: WfEdge[]) => {
|
|
117
|
+
onGraphChange?.(removeEdgesFromGraph(graph, edges));
|
|
118
|
+
},
|
|
119
|
+
[graph, onGraphChange]
|
|
120
|
+
);
|
|
121
|
+
|
|
92
122
|
const left = (
|
|
93
123
|
<DualPaneWorkspace
|
|
94
124
|
leftLabel="Nodes"
|
|
@@ -102,7 +132,13 @@ export function WorkflowEditor({
|
|
|
102
132
|
nodeTypes={nodeTypes}
|
|
103
133
|
onRename={handleRename}
|
|
104
134
|
onSelectNode={setSelectedId}
|
|
135
|
+
onSelectEdge={setSelectedEdgeId}
|
|
136
|
+
selectedNodeId={selectedId}
|
|
137
|
+
selectedEdgeId={selectedEdgeId}
|
|
105
138
|
onDropNodeType={(slug) => handleAddNode(slug)}
|
|
139
|
+
onConnectEdge={onGraphChange ? handleConnectEdge : undefined}
|
|
140
|
+
onDeleteNodes={onGraphChange ? handleDeleteNodes : undefined}
|
|
141
|
+
onDeleteEdges={onGraphChange ? handleDeleteEdges : undefined}
|
|
106
142
|
/>
|
|
107
143
|
}
|
|
108
144
|
/>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connectionToEdge,
|
|
3
|
+
addEdgeToGraph,
|
|
4
|
+
removeNodesFromGraph,
|
|
5
|
+
removeEdgesFromGraph,
|
|
6
|
+
} from '../graph-ops';
|
|
7
|
+
import type { WorkflowGraph } from '../types';
|
|
8
|
+
|
|
9
|
+
const graph: WorkflowGraph = {
|
|
10
|
+
nodes: [
|
|
11
|
+
{ id: 'a', name: 'A', type: 'trigger.event' },
|
|
12
|
+
{ id: 'b', name: 'B', type: 'transform.set' },
|
|
13
|
+
{ id: 'c', name: 'C', type: 'action.email' },
|
|
14
|
+
],
|
|
15
|
+
edges: [
|
|
16
|
+
{ source: 'a', target: 'b' },
|
|
17
|
+
{ source: 'b', target: 'c', sourceOutput: 1 },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('connectionToEdge', () => {
|
|
22
|
+
it('maps handle id strings to numeric output/input indexes', () => {
|
|
23
|
+
expect(
|
|
24
|
+
connectionToEdge({
|
|
25
|
+
source: 'b',
|
|
26
|
+
target: 'c',
|
|
27
|
+
sourceHandle: '1',
|
|
28
|
+
targetHandle: '0',
|
|
29
|
+
})
|
|
30
|
+
).toEqual({
|
|
31
|
+
source: 'b',
|
|
32
|
+
target: 'c',
|
|
33
|
+
sourceOutput: 1,
|
|
34
|
+
targetInput: 0,
|
|
35
|
+
type: 'main',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('defaults missing/null handles to index 0', () => {
|
|
40
|
+
expect(
|
|
41
|
+
connectionToEdge({ source: 'a', target: 'b', sourceHandle: null })
|
|
42
|
+
).toEqual({
|
|
43
|
+
source: 'a',
|
|
44
|
+
target: 'b',
|
|
45
|
+
sourceOutput: 0,
|
|
46
|
+
targetInput: 0,
|
|
47
|
+
type: 'main',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('addEdgeToGraph', () => {
|
|
53
|
+
it('appends a new edge', () => {
|
|
54
|
+
const next = addEdgeToGraph(graph, {
|
|
55
|
+
source: 'a',
|
|
56
|
+
target: 'c',
|
|
57
|
+
sourceOutput: 0,
|
|
58
|
+
targetInput: 0,
|
|
59
|
+
});
|
|
60
|
+
expect(next.edges).toHaveLength(3);
|
|
61
|
+
expect(next.edges).toContainEqual(
|
|
62
|
+
expect.objectContaining({ source: 'a', target: 'c' })
|
|
63
|
+
);
|
|
64
|
+
// does not mutate the input
|
|
65
|
+
expect(graph.edges).toHaveLength(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('is a no-op for a duplicate edge (same source/output/target/input)', () => {
|
|
69
|
+
const next = addEdgeToGraph(graph, {
|
|
70
|
+
source: 'a',
|
|
71
|
+
target: 'b',
|
|
72
|
+
sourceOutput: 0,
|
|
73
|
+
targetInput: 0,
|
|
74
|
+
});
|
|
75
|
+
expect(next.edges).toHaveLength(2);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('removeNodesFromGraph', () => {
|
|
80
|
+
it('removes the node AND every incident edge (as source or target)', () => {
|
|
81
|
+
const next = removeNodesFromGraph(graph, ['b']);
|
|
82
|
+
expect(next.nodes.map((n) => n.id)).toEqual(['a', 'c']);
|
|
83
|
+
// a->b and b->c both reference b, so both go
|
|
84
|
+
expect(next.edges).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keeps edges between surviving nodes', () => {
|
|
88
|
+
const next = removeNodesFromGraph(graph, ['c']);
|
|
89
|
+
expect(next.nodes.map((n) => n.id)).toEqual(['a', 'b']);
|
|
90
|
+
expect(next.edges).toEqual([{ source: 'a', target: 'b' }]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('removeEdgesFromGraph', () => {
|
|
95
|
+
it('removes only the matching edge (by source/target/output/input)', () => {
|
|
96
|
+
const next = removeEdgesFromGraph(graph, [
|
|
97
|
+
{ source: 'b', target: 'c', sourceOutput: 1, targetInput: 0 },
|
|
98
|
+
]);
|
|
99
|
+
expect(next.edges).toEqual([{ source: 'a', target: 'b' }]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure graph-mutation helpers for the workflow editor.
|
|
3
|
+
*
|
|
4
|
+
* The editor canvas is controlled — every mutation produces a NEW
|
|
5
|
+
* {@link WorkflowGraph} (never mutates the input) which the consumer feeds
|
|
6
|
+
* back through `onGraphChange`. Kept framework-free and side-effect-free so
|
|
7
|
+
* they're trivially unit-testable and reusable outside React.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { WfEdge, WorkflowGraph } from './types';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONNECTION_TYPE = 'main';
|
|
13
|
+
|
|
14
|
+
/** A minimal xyflow `Connection`-like shape (avoids an @xyflow/react import). */
|
|
15
|
+
export interface GraphConnection {
|
|
16
|
+
source: string;
|
|
17
|
+
target: string;
|
|
18
|
+
sourceHandle?: string | null;
|
|
19
|
+
targetHandle?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parse a handle id ("0", "1", null, "") into its numeric index (default 0). */
|
|
23
|
+
function handleIndex(handle: string | null | undefined): number {
|
|
24
|
+
if (handle === null || handle === undefined || handle === '') return 0;
|
|
25
|
+
const n = Number.parseInt(handle, 10);
|
|
26
|
+
return Number.isNaN(n) ? 0 : n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when two edges connect the same source-output → target-input. */
|
|
30
|
+
function sameEdge(a: WfEdge, b: WfEdge): boolean {
|
|
31
|
+
return (
|
|
32
|
+
a.source === b.source &&
|
|
33
|
+
a.target === b.target &&
|
|
34
|
+
(a.sourceOutput ?? 0) === (b.sourceOutput ?? 0) &&
|
|
35
|
+
(a.targetInput ?? 0) === (b.targetInput ?? 0)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Map an xyflow {@link GraphConnection} (handle ids are output/input index
|
|
41
|
+
* strings) to a logical {@link WfEdge}.
|
|
42
|
+
*/
|
|
43
|
+
export function connectionToEdge(conn: GraphConnection): WfEdge {
|
|
44
|
+
return {
|
|
45
|
+
source: conn.source,
|
|
46
|
+
target: conn.target,
|
|
47
|
+
sourceOutput: handleIndex(conn.sourceHandle),
|
|
48
|
+
targetInput: handleIndex(conn.targetHandle),
|
|
49
|
+
type: DEFAULT_CONNECTION_TYPE,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Append an edge, ignoring exact duplicates. Returns a new graph. */
|
|
54
|
+
export function addEdgeToGraph(
|
|
55
|
+
graph: WorkflowGraph,
|
|
56
|
+
edge: WfEdge
|
|
57
|
+
): WorkflowGraph {
|
|
58
|
+
if (graph.edges.some((e) => sameEdge(e, edge))) return graph;
|
|
59
|
+
return { ...graph, edges: [...graph.edges, edge] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Remove the given nodes AND every edge incident to them (as source or
|
|
64
|
+
* target). Returns a new graph.
|
|
65
|
+
*/
|
|
66
|
+
export function removeNodesFromGraph(
|
|
67
|
+
graph: WorkflowGraph,
|
|
68
|
+
nodeIds: string[]
|
|
69
|
+
): WorkflowGraph {
|
|
70
|
+
const dropped = new Set(nodeIds);
|
|
71
|
+
return {
|
|
72
|
+
...graph,
|
|
73
|
+
nodes: graph.nodes.filter((n) => !dropped.has(n.id)),
|
|
74
|
+
edges: graph.edges.filter(
|
|
75
|
+
(e) => !dropped.has(e.source) && !dropped.has(e.target)
|
|
76
|
+
),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Remove the given edges (matched by source/target/output/input). New graph. */
|
|
81
|
+
export function removeEdgesFromGraph(
|
|
82
|
+
graph: WorkflowGraph,
|
|
83
|
+
edges: WfEdge[]
|
|
84
|
+
): WorkflowGraph {
|
|
85
|
+
return {
|
|
86
|
+
...graph,
|
|
87
|
+
edges: graph.edges.filter((e) => !edges.some((d) => sameEdge(e, d))),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -39,6 +39,15 @@ export type { FlowNode, FlowEdge } from './serialization';
|
|
|
39
39
|
export { autoLayout, layoutLR, layoutTB } from './layout/auto-layout';
|
|
40
40
|
export type { LayoutDirection, AutoLayoutOptions } from './layout/auto-layout';
|
|
41
41
|
|
|
42
|
+
// Graph mutation (pure helpers used by the editor; also reusable standalone)
|
|
43
|
+
export {
|
|
44
|
+
connectionToEdge,
|
|
45
|
+
addEdgeToGraph,
|
|
46
|
+
removeNodesFromGraph,
|
|
47
|
+
removeEdgesFromGraph,
|
|
48
|
+
} from './graph-ops';
|
|
49
|
+
export type { GraphConnection } from './graph-ops';
|
|
50
|
+
|
|
42
51
|
// Theme
|
|
43
52
|
export {
|
|
44
53
|
CATEGORY_TOKENS,
|