@eventcatalog/core 5.0.0-beta.4 → 5.0.0-beta.6
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/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +2 -2
- package/dist/analytics/log-build.js +4 -4
- package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
- package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
- package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
- package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
- package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
- package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +6 -6
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
- package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +8 -0
- package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
- package/dist/runtime-dependencies/yjs.mjs +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +1 -1
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/astro.config.mjs +2 -0
- package/eventcatalog/integrations/studio-server.mjs +17 -0
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
- package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
- package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
- package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
- package/eventcatalog/src/features/studio/catalog.ts +171 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
- package/eventcatalog/src/features/studio/components/icons.ts +48 -0
- package/eventcatalog/src/features/studio/components/status.ts +22 -0
- package/eventcatalog/src/features/studio/edges.ts +54 -0
- package/eventcatalog/src/features/studio/flow-state.ts +92 -0
- package/eventcatalog/src/features/studio/grouping.ts +133 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
- package/eventcatalog/src/features/studio/layout.ts +39 -0
- package/eventcatalog/src/features/studio/levels.ts +216 -0
- package/eventcatalog/src/features/studio/node-types.ts +173 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
- package/eventcatalog/src/features/studio/pages/index.astro +8 -0
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
- package/eventcatalog/src/features/studio/placement.ts +197 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
- package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
- package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
- package/eventcatalog/src/features/studio/studio.css +89 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
- package/eventcatalog/src/utils/feature.ts +4 -0
- package/eventcatalog/src/utils/trial.ts +1 -1
- package/package.json +9 -2
- package/eventcatalog/public/studio-bg.png +0 -0
- package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
- package/eventcatalog/src/pages/studio.astro +0 -316
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import * as Y from 'yjs';
|
|
3
|
+
import { HocuspocusProvider } from '@hocuspocus/provider';
|
|
4
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
5
|
+
import {
|
|
6
|
+
applyEdgeChanges,
|
|
7
|
+
applyNodeChanges,
|
|
8
|
+
type Connection,
|
|
9
|
+
type Edge,
|
|
10
|
+
type EdgeChange,
|
|
11
|
+
type Node,
|
|
12
|
+
type NodeChange,
|
|
13
|
+
type XYPosition,
|
|
14
|
+
} from '@xyflow/react';
|
|
15
|
+
import {
|
|
16
|
+
addNodes,
|
|
17
|
+
buildNode,
|
|
18
|
+
canvasDocumentName,
|
|
19
|
+
connectNodes,
|
|
20
|
+
deleteEdges,
|
|
21
|
+
deleteNodes as deleteNodesInDoc,
|
|
22
|
+
fitGroupToChildren,
|
|
23
|
+
getCanvasMaps,
|
|
24
|
+
moveNode,
|
|
25
|
+
readMeta,
|
|
26
|
+
resizeNode,
|
|
27
|
+
setMeta,
|
|
28
|
+
setNodeParent,
|
|
29
|
+
updateNodeData as updateNodeDataInDoc,
|
|
30
|
+
} from '../canvas-doc';
|
|
31
|
+
import { applyLayout } from '../agent-choreography';
|
|
32
|
+
import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
|
|
33
|
+
import { findGroupAtPoint, toRelativePosition } from '../grouping';
|
|
34
|
+
import type { LayoutResult } from '../layout';
|
|
35
|
+
import { getNodeDefinition } from '../node-types';
|
|
36
|
+
import { startToolSync, type ToolSync } from '../tool-sync';
|
|
37
|
+
import { createPresenceSender, type PresenceSender } from './presence-sender';
|
|
38
|
+
import { createPresenceStore, type PresenceStore } from './presence-store';
|
|
39
|
+
|
|
40
|
+
export type { Peer } from './presence-store';
|
|
41
|
+
|
|
42
|
+
/** How long the WebSocket gets to connect before a view that can sync through tool calls switches to them */
|
|
43
|
+
const SOCKET_TIMEOUT_MS = 4000;
|
|
44
|
+
/**
|
|
45
|
+
* After someone drops what they were dragging, how long until it goes back to where the document has it, if the
|
|
46
|
+
* drop doesn't arrive (they left mid-drag). The drop normally arrives first, or within a few milliseconds.
|
|
47
|
+
*/
|
|
48
|
+
const DROP_SETTLE_MS = 500;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Changes made here that are already shown here (nodes dragged and resized with React Flow): the document
|
|
52
|
+
* observers skip them, and undo includes them
|
|
53
|
+
*/
|
|
54
|
+
const LOCAL_ORIGIN = Symbol('studio-local');
|
|
55
|
+
/** Changes an agent working through this tab (WebMCP) makes: synced like anyone's, and not in the user's undo */
|
|
56
|
+
export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
|
|
57
|
+
|
|
58
|
+
export type Status = 'connecting' | 'connected' | 'disconnected';
|
|
59
|
+
export type Transport = 'websocket' | 'tools';
|
|
60
|
+
|
|
61
|
+
type Shared = { doc: Y.Doc; awareness: Awareness; undoManager: Y.UndoManager; sender: PresenceSender };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A React Flow design kept in a Yjs document (one map of nodes, one of edges), synced through Hocuspocus.
|
|
65
|
+
* Presence (who is here, their pointer and what they have selected) goes over Yjs awareness, in a store
|
|
66
|
+
* (see presence-store) rather than React state.
|
|
67
|
+
*
|
|
68
|
+
* Nodes being dragged go to everyone with our presence (with the pointer, about 30 times a second), and only
|
|
69
|
+
* where they're dropped is saved in the document: one change per drag, so the document doesn't grow with every
|
|
70
|
+
* step of every drag, and undo takes back the whole drag. Others show where we're dragging them as we do.
|
|
71
|
+
*/
|
|
72
|
+
export function useStudioFlow({
|
|
73
|
+
canvasId,
|
|
74
|
+
socketUrl,
|
|
75
|
+
name,
|
|
76
|
+
color,
|
|
77
|
+
syncViaTools,
|
|
78
|
+
onLayout,
|
|
79
|
+
}: {
|
|
80
|
+
canvasId: string;
|
|
81
|
+
/** e.g. wss://catalog.example.com/_eventcatalog/studio */
|
|
82
|
+
socketUrl: string;
|
|
83
|
+
name: string;
|
|
84
|
+
color: string;
|
|
85
|
+
/** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
|
|
86
|
+
syncViaTools?: ToolSync;
|
|
87
|
+
/** Someone laid the canvas out (us included): called as the new positions arrive, so they can glide there */
|
|
88
|
+
onLayout?: () => void;
|
|
89
|
+
}) {
|
|
90
|
+
const sharedRef = useRef<Shared | null>(null);
|
|
91
|
+
const [nodes, setNodes] = useState<Node[]>([]);
|
|
92
|
+
const [edges, setEdges] = useState<Edge[]>([]);
|
|
93
|
+
const [status, setStatus] = useState<Status>('connecting');
|
|
94
|
+
const [presence, setPresence] = useState<PresenceStore | null>(null);
|
|
95
|
+
const [clientId, setClientId] = useState<number | null>(null);
|
|
96
|
+
// Exposed so other shared state (e.g. comments) can live in the same document
|
|
97
|
+
const [doc, setDoc] = useState<Y.Doc | null>(null);
|
|
98
|
+
const [history, setHistory] = useState({ canUndo: false, canRedo: false });
|
|
99
|
+
const [title, setTitle] = useState<string | undefined>();
|
|
100
|
+
const [transport, setTransport] = useState<Transport>('websocket');
|
|
101
|
+
const user = useRef({ name, color });
|
|
102
|
+
user.current = { name, color };
|
|
103
|
+
const onLayoutRef = useRef(onLayout);
|
|
104
|
+
onLayoutRef.current = onLayout;
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
// A new connection starts from what the server has. Clear anything left from a previous one,
|
|
108
|
+
// since an empty document never fires an update that would remove it.
|
|
109
|
+
setNodes([]);
|
|
110
|
+
setEdges([]);
|
|
111
|
+
|
|
112
|
+
const doc = new Y.Doc();
|
|
113
|
+
const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
|
|
114
|
+
const syncMeta = (event: Y.YMapEvent<unknown>) => {
|
|
115
|
+
setTitle(readMeta(doc).title);
|
|
116
|
+
if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
|
|
117
|
+
};
|
|
118
|
+
yMeta.observe(syncMeta);
|
|
119
|
+
setTitle(undefined);
|
|
120
|
+
// One presence for whichever connection is used
|
|
121
|
+
const awareness = new Awareness(doc);
|
|
122
|
+
awareness.setLocalStateField('user', user.current);
|
|
123
|
+
const presenceStore = createPresenceStore(awareness);
|
|
124
|
+
setPresence(presenceStore);
|
|
125
|
+
const sender = createPresenceSender(awareness);
|
|
126
|
+
let socketConnected = false;
|
|
127
|
+
let stopToolSync: (() => void) | undefined;
|
|
128
|
+
const provider = new HocuspocusProvider({
|
|
129
|
+
url: socketUrl,
|
|
130
|
+
name: canvasDocumentName(canvasId),
|
|
131
|
+
document: doc,
|
|
132
|
+
awareness,
|
|
133
|
+
onStatus: ({ status }) => {
|
|
134
|
+
if (status === 'connected') socketConnected = true;
|
|
135
|
+
socketStatus = status as Status;
|
|
136
|
+
if (!stopToolSync) setStatus(status as Status);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
let socketStatus: Status = 'connecting';
|
|
140
|
+
setTransport('websocket');
|
|
141
|
+
|
|
142
|
+
// Back online, or back on the tab: reconnect now rather than when the next retry is due
|
|
143
|
+
const reconnect = () => {
|
|
144
|
+
if (!stopToolSync && socketStatus === 'disconnected') void provider.connect();
|
|
145
|
+
};
|
|
146
|
+
// Away from the tab: our pointer goes from everyone's canvas
|
|
147
|
+
const onVisibilityChange = () => {
|
|
148
|
+
if (document.visibilityState === 'visible') return reconnect();
|
|
149
|
+
sender.send({ pointer: null });
|
|
150
|
+
sender.flush();
|
|
151
|
+
};
|
|
152
|
+
window.addEventListener('online', reconnect);
|
|
153
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
154
|
+
|
|
155
|
+
// Some views can't open the WebSocket (a chat's sandbox may block it): sync through tool calls instead
|
|
156
|
+
const fallback = syncViaTools
|
|
157
|
+
? setTimeout(() => {
|
|
158
|
+
if (socketConnected) return;
|
|
159
|
+
provider.disconnect();
|
|
160
|
+
setTransport('tools');
|
|
161
|
+
stopToolSync = startToolSync(doc, awareness, { canvasId, sync: syncViaTools, onStatus: setStatus });
|
|
162
|
+
}, SOCKET_TIMEOUT_MS)
|
|
163
|
+
: undefined;
|
|
164
|
+
// Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
|
|
165
|
+
const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
|
|
166
|
+
captureTimeout: 500,
|
|
167
|
+
trackedOrigins: new Set([null, LOCAL_ORIGIN]),
|
|
168
|
+
});
|
|
169
|
+
const updateHistory = () =>
|
|
170
|
+
setHistory({ canUndo: undoManager.undoStack.length > 0, canRedo: undoManager.redoStack.length > 0 });
|
|
171
|
+
undoManager.on('stack-item-added', updateHistory);
|
|
172
|
+
undoManager.on('stack-item-popped', updateHistory);
|
|
173
|
+
undoManager.on('stack-cleared', updateHistory);
|
|
174
|
+
setHistory({ canUndo: false, canRedo: false });
|
|
175
|
+
sharedRef.current = { doc, awareness, undoManager, sender };
|
|
176
|
+
setClientId(doc.clientID);
|
|
177
|
+
setDoc(doc);
|
|
178
|
+
|
|
179
|
+
// Where others are dragging nodes, from their presence, until they drop them
|
|
180
|
+
let remoteMoves = new Map<string, XYPosition>();
|
|
181
|
+
const settling = new Set<ReturnType<typeof setTimeout>>();
|
|
182
|
+
const syncMoves = (_: unknown, origin: unknown) => {
|
|
183
|
+
if (origin === 'local') return;
|
|
184
|
+
const next = new Map<string, XYPosition>();
|
|
185
|
+
awareness.getStates().forEach((state, id) => {
|
|
186
|
+
if (id === doc.clientID || !state.moving) return;
|
|
187
|
+
Object.entries(state.moving as Record<string, XYPosition>).forEach(([nodeId, position]) => next.set(nodeId, position));
|
|
188
|
+
});
|
|
189
|
+
const moved = new Map([...next].filter(([id, { x, y }]) => remoteMoves.get(id)?.x !== x || remoteMoves.get(id)?.y !== y));
|
|
190
|
+
const dropped = [...remoteMoves.keys()].filter((id) => !next.has(id));
|
|
191
|
+
remoteMoves = next;
|
|
192
|
+
if (moved.size) setNodes((local) => withPositions(local, moved));
|
|
193
|
+
if (dropped.length === 0) return;
|
|
194
|
+
// The drop is saved in the document. If it doesn't arrive, back to where the document has it.
|
|
195
|
+
const settle = setTimeout(() => {
|
|
196
|
+
settling.delete(settle);
|
|
197
|
+
const saved = dropped.flatMap((id) => {
|
|
198
|
+
const node = remoteMoves.has(id) ? undefined : yNodes.get(id);
|
|
199
|
+
return node ? [[id, node.position] as const] : [];
|
|
200
|
+
});
|
|
201
|
+
setNodes((local) => withPositions(local, new Map(saved)));
|
|
202
|
+
}, DROP_SETTLE_MS);
|
|
203
|
+
settling.add(settle);
|
|
204
|
+
};
|
|
205
|
+
awareness.on('change', syncMoves);
|
|
206
|
+
|
|
207
|
+
// Only what changed is replaced, so React Flow re-renders just those nodes
|
|
208
|
+
const syncNodes = (event: Y.YMapEvent<Node>) => {
|
|
209
|
+
if (event.transaction.origin === LOCAL_ORIGIN) return;
|
|
210
|
+
// A node someone was dragging changed in the document: they dropped it there
|
|
211
|
+
event.keysChanged.forEach((id) => remoteMoves.delete(id));
|
|
212
|
+
setNodes((local) =>
|
|
213
|
+
keepDragged(
|
|
214
|
+
patchShared(local, event.keysChanged, (id) => yNodes.get(id), LOCAL_NODE_KEYS),
|
|
215
|
+
local
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
const syncEdges = (event: Y.YMapEvent<Edge>) => {
|
|
220
|
+
if (event.transaction.origin === LOCAL_ORIGIN) return;
|
|
221
|
+
setEdges((local) => patchShared(local, event.keysChanged, (id) => yEdges.get(id), LOCAL_EDGE_KEYS));
|
|
222
|
+
};
|
|
223
|
+
yNodes.observe(syncNodes);
|
|
224
|
+
yEdges.observe(syncEdges);
|
|
225
|
+
|
|
226
|
+
return () => {
|
|
227
|
+
clearTimeout(fallback);
|
|
228
|
+
settling.forEach(clearTimeout);
|
|
229
|
+
window.removeEventListener('online', reconnect);
|
|
230
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
231
|
+
awareness.off('change', syncMoves);
|
|
232
|
+
sender.destroy();
|
|
233
|
+
stopToolSync?.();
|
|
234
|
+
yNodes.unobserve(syncNodes);
|
|
235
|
+
yEdges.unobserve(syncEdges);
|
|
236
|
+
yMeta.unobserve(syncMeta);
|
|
237
|
+
presenceStore.destroy();
|
|
238
|
+
undoManager.destroy();
|
|
239
|
+
provider.destroy();
|
|
240
|
+
doc.destroy();
|
|
241
|
+
sharedRef.current = null;
|
|
242
|
+
setDoc(null);
|
|
243
|
+
setPresence(null);
|
|
244
|
+
};
|
|
245
|
+
}, [canvasId, socketUrl, syncViaTools]);
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
sharedRef.current?.awareness.setLocalStateField('user', { name, color });
|
|
249
|
+
}, [name, color]);
|
|
250
|
+
|
|
251
|
+
// Tell others what we have selected so they can see it
|
|
252
|
+
const selectionKey = useMemo(
|
|
253
|
+
() =>
|
|
254
|
+
nodes
|
|
255
|
+
.filter((node) => node.selected)
|
|
256
|
+
.map((node) => node.id)
|
|
257
|
+
.join(','),
|
|
258
|
+
[nodes]
|
|
259
|
+
);
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
sharedRef.current?.sender.send({ selection: selectionKey ? selectionKey.split(',') : [] });
|
|
262
|
+
}, [selectionKey]);
|
|
263
|
+
|
|
264
|
+
const withDoc = <T>(fn: (doc: Y.Doc) => T, origin: unknown = null) => {
|
|
265
|
+
const doc = sharedRef.current?.doc;
|
|
266
|
+
return doc ? doc.transact(() => fn(doc), origin) : undefined;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Where the nodes being dragged are, until they're dropped
|
|
270
|
+
const dragged = useRef(new Map<string, XYPosition>());
|
|
271
|
+
/** Saves where dragged nodes were dropped (in the transaction it's called in), and stops showing them as dragged */
|
|
272
|
+
const saveDrop = (doc: Y.Doc) => {
|
|
273
|
+
dragged.current.forEach((position, id) => moveNode(doc, id, position));
|
|
274
|
+
dragged.current.clear();
|
|
275
|
+
const shared = sharedRef.current;
|
|
276
|
+
shared?.sender.send({ moving: null });
|
|
277
|
+
// After the drop is in the document (it's sent as the transaction ends)
|
|
278
|
+
queueMicrotask(() => shared?.sender.flush());
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
|
282
|
+
setNodes((local) => applyNodeChanges(changes, local));
|
|
283
|
+
let dropped = false;
|
|
284
|
+
const resized: { id: string; dimensions: { width: number; height: number } }[] = [];
|
|
285
|
+
for (const change of changes) {
|
|
286
|
+
if (change.type === 'position' && change.position) {
|
|
287
|
+
dragged.current.set(change.id, change.position);
|
|
288
|
+
if (!change.dragging) dropped = true;
|
|
289
|
+
}
|
|
290
|
+
// Containers resized with their handles
|
|
291
|
+
if (change.type === 'dimensions' && change.setAttributes && change.dimensions)
|
|
292
|
+
resized.push({ id: change.id, dimensions: change.dimensions });
|
|
293
|
+
}
|
|
294
|
+
// While dragging, nodes move here every frame and go to everyone with our presence; where they're dropped is saved
|
|
295
|
+
if (dropped) {
|
|
296
|
+
// Each drag is its own step to undo
|
|
297
|
+
sharedRef.current?.undoManager.stopCapturing();
|
|
298
|
+
withDoc(saveDrop, LOCAL_ORIGIN);
|
|
299
|
+
} else if (dragged.current.size) sharedRef.current?.sender.send({ moving: Object.fromEntries(dragged.current) });
|
|
300
|
+
if (resized.length) withDoc((doc) => resized.forEach(({ id, dimensions }) => resizeNode(doc, id, dimensions)), LOCAL_ORIGIN);
|
|
301
|
+
// Removing a node also removes what's in it, its edges, and moves its comments: those come back through the observers
|
|
302
|
+
const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
|
|
303
|
+
if (removed.length) withDoc((doc) => deleteNodesInDoc(doc, removed));
|
|
304
|
+
}, []);
|
|
305
|
+
|
|
306
|
+
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
|
307
|
+
setEdges((local) => applyEdgeChanges(changes, local));
|
|
308
|
+
const removed = changes.filter((change) => change.type === 'remove').map((change) => change.id);
|
|
309
|
+
if (removed.length) withDoc((doc) => deleteEdges(doc, removed));
|
|
310
|
+
}, []);
|
|
311
|
+
|
|
312
|
+
const onConnect = useCallback((connection: Connection) => {
|
|
313
|
+
withDoc((doc) => connectNodes(doc, connection));
|
|
314
|
+
}, []);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Add a node centred on a canvas position. `connect` gets the new node's id and the nodes already
|
|
318
|
+
* on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
|
|
319
|
+
*/
|
|
320
|
+
const insertNode = useCallback(
|
|
321
|
+
(type: string, data: Record<string, unknown>, center: XYPosition, connect?: (id: string, existing: Node[]) => Edge[]) => {
|
|
322
|
+
const built = buildNode(type, data, center);
|
|
323
|
+
withDoc((doc) => {
|
|
324
|
+
const existing = Array.from(getCanvasMaps(doc).nodes.values());
|
|
325
|
+
// Dropped on a container: it goes in it
|
|
326
|
+
const group = findGroupAtPoint(center, existing);
|
|
327
|
+
const node = group
|
|
328
|
+
? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
|
|
329
|
+
: built;
|
|
330
|
+
addNodes(doc, [node], connect?.(node.id, existing) ?? []);
|
|
331
|
+
if (group) fitGroupToChildren(doc, group.id);
|
|
332
|
+
});
|
|
333
|
+
return built.id;
|
|
334
|
+
},
|
|
335
|
+
[]
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
/** Add a node of a registry type, centred on a canvas position */
|
|
339
|
+
const addNode = useCallback(
|
|
340
|
+
(type: string, center: XYPosition) => {
|
|
341
|
+
const definition = getNodeDefinition(type);
|
|
342
|
+
return definition ? insertNode(type, definition.createData(), center) : undefined;
|
|
343
|
+
},
|
|
344
|
+
[insertNode]
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const updateNodeData = useCallback(
|
|
348
|
+
(id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) =>
|
|
349
|
+
withDoc((doc) => updateNodeDataInDoc(doc, id, updater)),
|
|
350
|
+
[]
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const deleteNodes = useCallback((ids: string[]) => withDoc((doc) => deleteNodesInDoc(doc, ids)), []);
|
|
354
|
+
|
|
355
|
+
/** Moves nodes (and resizes containers) from a layout in one change, so it undoes in one go */
|
|
356
|
+
const applyPositions = useCallback((layout: LayoutResult) => withDoc((doc) => applyLayout(doc, layout)), []);
|
|
357
|
+
|
|
358
|
+
/** Adds ready-made nodes and edges (e.g. a container with what's in it) in one change */
|
|
359
|
+
const insertNodes = useCallback(
|
|
360
|
+
(nodes: Node[], edges: Edge[] = []) =>
|
|
361
|
+
withDoc((doc) => {
|
|
362
|
+
addNodes(doc, nodes, edges);
|
|
363
|
+
// Containers already on the canvas that got something new inside grow to fit it
|
|
364
|
+
const added = new Set(nodes.map((node) => node.id));
|
|
365
|
+
new Set(nodes.flatMap((node) => (node.parentId && !added.has(node.parentId) ? [node.parentId] : []))).forEach((groupId) =>
|
|
366
|
+
fitGroupToChildren(doc, groupId)
|
|
367
|
+
);
|
|
368
|
+
}),
|
|
369
|
+
[]
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
/** Puts a node in a container (or takes it out), e.g. after it's dragged there */
|
|
373
|
+
const setParent = useCallback(
|
|
374
|
+
(nodeId: string, parentId: string | undefined) =>
|
|
375
|
+
withDoc((doc) => {
|
|
376
|
+
// Where it was dropped goes first, so it's moved into (or out of) the container from there
|
|
377
|
+
if (dragged.current.size) saveDrop(doc);
|
|
378
|
+
return setNodeParent(doc, nodeId, parentId);
|
|
379
|
+
}),
|
|
380
|
+
[]
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const rename = useCallback((next: string) => withDoc((doc) => setMeta(doc, { title: next || undefined })), []);
|
|
384
|
+
|
|
385
|
+
const undo = useCallback(() => sharedRef.current?.undoManager.undo(), []);
|
|
386
|
+
const redo = useCallback(() => sharedRef.current?.undoManager.redo(), []);
|
|
387
|
+
|
|
388
|
+
/** Our pointer on the canvas, for everyone (sent about 30 times a second, so call it on every move) */
|
|
389
|
+
const setPointer = useCallback((pointer: XYPosition | null) => {
|
|
390
|
+
sharedRef.current?.sender.send({ pointer });
|
|
391
|
+
}, []);
|
|
392
|
+
|
|
393
|
+
const setViewport = useCallback((viewport: { x: number; y: number; zoom: number }) => {
|
|
394
|
+
sharedRef.current?.sender.send({ viewport });
|
|
395
|
+
}, []);
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
nodes,
|
|
399
|
+
edges,
|
|
400
|
+
status,
|
|
401
|
+
presence,
|
|
402
|
+
clientId,
|
|
403
|
+
doc,
|
|
404
|
+
onNodesChange,
|
|
405
|
+
onEdgesChange,
|
|
406
|
+
onConnect,
|
|
407
|
+
addNode,
|
|
408
|
+
insertNode,
|
|
409
|
+
updateNodeData,
|
|
410
|
+
deleteNodes,
|
|
411
|
+
applyPositions,
|
|
412
|
+
insertNodes,
|
|
413
|
+
setParent,
|
|
414
|
+
title,
|
|
415
|
+
rename,
|
|
416
|
+
transport,
|
|
417
|
+
undo,
|
|
418
|
+
redo,
|
|
419
|
+
...history,
|
|
420
|
+
setPointer,
|
|
421
|
+
setViewport,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
|
+
import { layoutWithElk } from '@eventcatalog/visualiser/layout';
|
|
3
|
+
import { isGroupType } from './node-types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lays a canvas out with the same ELK layout as EventCatalog's visualiser (left to right, edges routed
|
|
7
|
+
* around nodes, containers laid out with what's in them and sized to fit). Nodes are sized as rendered when
|
|
8
|
+
* they have been (in the browser), or by their type.
|
|
9
|
+
*/
|
|
10
|
+
export const layoutGraph = (nodes: Node[], edges: Edge[]) => layoutWithElk({ nodes, edges });
|
|
11
|
+
|
|
12
|
+
/** The layout sizes containers with style.width/height; the canvas sizes them with width/height */
|
|
13
|
+
export const withGroupSizes = (nodes: Node[]) =>
|
|
14
|
+
nodes.map((node) => {
|
|
15
|
+
const { width, height, ...style } = node.style ?? {};
|
|
16
|
+
if (typeof width !== 'number' || typeof height !== 'number') return node;
|
|
17
|
+
return { ...node, width, height, style };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type LayoutResult = Map<string, { position: XYPosition; size?: { width: number; height: number } }>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* New positions (relative to their container, if they're in one) for the nodes on a canvas, and new sizes for
|
|
24
|
+
* containers. Only these are kept for the canvas people edit: the edge routes ELK draws would be out of date
|
|
25
|
+
* as soon as someone moves a node.
|
|
26
|
+
*/
|
|
27
|
+
export const getLayoutPositions = async (nodes: Node[], edges: Edge[]): Promise<LayoutResult> => {
|
|
28
|
+
if (nodes.length === 0) return new Map();
|
|
29
|
+
const laidOut = withGroupSizes((await layoutGraph(nodes, edges)).nodes);
|
|
30
|
+
return new Map(
|
|
31
|
+
laidOut.map((node) => [
|
|
32
|
+
node.id,
|
|
33
|
+
{
|
|
34
|
+
position: node.position,
|
|
35
|
+
...(isGroupType(node.type) && node.width && node.height && { size: { width: node.width, height: node.height } }),
|
|
36
|
+
},
|
|
37
|
+
])
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { Edge, Node } from '@xyflow/react';
|
|
2
|
+
import { getMessagesLabel } from '@eventcatalog/visualiser/layout';
|
|
3
|
+
import { EDGE_MARKER, MESSAGE_COLLECTIONS } from './edges';
|
|
4
|
+
import { GROUP_TYPES, isGroupType } from './node-types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Levels of detail, the same as EventCatalog's diagrams:
|
|
8
|
+
* - L1: domains, systems and how they relate (services folded into the system or domain that contains them)
|
|
9
|
+
* - L2: services and data stores, with messages and channels hidden (the nodes either side connected directly)
|
|
10
|
+
* - L3: everything, including messages and channels (the canvas people edit)
|
|
11
|
+
*
|
|
12
|
+
* What a domain or system contains is what sits inside it (as a container), or what it's connected to with
|
|
13
|
+
* "contains" (as a card, which catalog domains and systems get when their services are on the canvas).
|
|
14
|
+
*/
|
|
15
|
+
export type Level = 1 | 2 | 3;
|
|
16
|
+
|
|
17
|
+
export const LEVELS: { level: Level; description: string }[] = [
|
|
18
|
+
{ level: 1, description: 'Domains, systems and their relationships' },
|
|
19
|
+
{ level: 2, description: 'Services and data stores' },
|
|
20
|
+
{ level: 3, description: 'Everything, including messages and channels' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Domains and systems: as cards (what they contain is connected with "contains") or containers (it's inside)
|
|
24
|
+
const CONTAINER_TYPES = new Set(['system', 'context-domain', GROUP_TYPES.system, GROUP_TYPES.domain]);
|
|
25
|
+
const isSystem = (node: Node) => node.type === 'system' || node.type === GROUP_TYPES.system;
|
|
26
|
+
const COLLAPSED_GROUP_SIZE = { width: 280, height: 110 };
|
|
27
|
+
|
|
28
|
+
const isMessage = (node: Node) => !!MESSAGE_COLLECTIONS[node.type ?? ''];
|
|
29
|
+
const isCarrier = (node: Node) => isMessage(node) || node.type === 'channel';
|
|
30
|
+
const isContainer = (node: Node) => CONTAINER_TYPES.has(node.type ?? '');
|
|
31
|
+
const messageName = (node: Node) => String((node.data as { message?: { name?: string } }).message?.name ?? node.id);
|
|
32
|
+
|
|
33
|
+
/** What a node becomes at a level: kept (as itself or another node), carried through (messages, channels) or dropped */
|
|
34
|
+
type Representative = { kind: 'node'; id: string } | { kind: 'carrier' } | { kind: 'drop' };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Collapse a graph: each node is kept, replaced by another node (e.g. a service by its system),
|
|
38
|
+
* carried through (messages and channels: the nodes either side get connected directly) or dropped.
|
|
39
|
+
* Edges between the same two nodes are merged, labelled with the messages they carry.
|
|
40
|
+
*/
|
|
41
|
+
const collapseGraph = (nodes: Node[], edges: Edge[], representativeOf: (node: Node) => Representative) => {
|
|
42
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
43
|
+
// Whether one node is inside another (e.g. a system in its domain): they're not connected, one holds the other
|
|
44
|
+
const isInside = (id: string, containerId: string) => {
|
|
45
|
+
for (let node = byId.get(id); node?.parentId; node = byId.get(node.parentId)) if (node.parentId === containerId) return true;
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
const representatives = new Map(nodes.map((node) => [node.id, representativeOf(node)]));
|
|
49
|
+
const outgoing = new Map<string, Edge[]>();
|
|
50
|
+
edges.forEach((edge) => outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]));
|
|
51
|
+
|
|
52
|
+
type Merged = { source: string; target: string; messages: Map<string, string>; labels: string[] };
|
|
53
|
+
const merged = new Map<string, Merged>();
|
|
54
|
+
const addMessage = (messages: Map<string, string>, node: Node) => {
|
|
55
|
+
if (isMessage(node)) messages.set(messageName(node), MESSAGE_COLLECTIONS[node.type!]);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
edges.forEach((edge) => {
|
|
59
|
+
const from = representatives.get(edge.source);
|
|
60
|
+
if (from?.kind !== 'node') return;
|
|
61
|
+
// Follow the edge through carried nodes to the node(s) it reaches, collecting the messages on the way
|
|
62
|
+
const stack = [{ id: edge.target, messages: new Map<string, string>(), labels: [String(edge.label ?? '')] }];
|
|
63
|
+
const visited = new Set<string>();
|
|
64
|
+
addMessage(stack[0].messages, byId.get(edge.source)!);
|
|
65
|
+
while (stack.length) {
|
|
66
|
+
const current = stack.pop()!;
|
|
67
|
+
if (visited.has(current.id)) continue;
|
|
68
|
+
visited.add(current.id);
|
|
69
|
+
const node = byId.get(current.id);
|
|
70
|
+
const to = representatives.get(current.id);
|
|
71
|
+
if (!node || !to || to.kind === 'drop') continue;
|
|
72
|
+
const messages = new Map(current.messages);
|
|
73
|
+
addMessage(messages, node);
|
|
74
|
+
if (to.kind === 'carrier') {
|
|
75
|
+
(outgoing.get(current.id) ?? []).forEach((next) => stack.push({ id: next.target, messages, labels: [] as string[] }));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Edges inside what was collapsed disappear, and so do edges to what a node is in (e.g. a system and the
|
|
79
|
+
// messages left loose in its domain, which fold into the domain)
|
|
80
|
+
if (from.id === to.id || isInside(from.id, to.id) || isInside(to.id, from.id)) continue;
|
|
81
|
+
const key = `${from.id}->${to.id}`;
|
|
82
|
+
const entry: Merged = merged.get(key) ?? { source: from.id, target: to.id, messages: new Map(), labels: [] };
|
|
83
|
+
messages.forEach((collection, name) => entry.messages.set(name, collection));
|
|
84
|
+
entry.labels.push(...current.labels.filter(Boolean));
|
|
85
|
+
merged.set(key, entry);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const keptIds = new Set([...representatives.values()].flatMap((rep) => (rep.kind === 'node' ? [rep.id] : [])));
|
|
90
|
+
return {
|
|
91
|
+
nodes: nodes.filter((node) => keptIds.has(node.id)),
|
|
92
|
+
edges: [...merged.values()].map(({ source, target, messages, labels }): Edge => {
|
|
93
|
+
const collections = new Set(messages.values());
|
|
94
|
+
const collection = collections.size === 1 ? [...collections][0] : undefined;
|
|
95
|
+
return {
|
|
96
|
+
id: `level-${source}-${target}`,
|
|
97
|
+
source,
|
|
98
|
+
target,
|
|
99
|
+
type: collection ? 'animated' : 'smoothstep',
|
|
100
|
+
label: getMessagesLabel(messages) ?? [...new Set(labels)].join(', '),
|
|
101
|
+
markerEnd: EDGE_MARKER,
|
|
102
|
+
data: collection ? { message: { collection } } : {},
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The system and domain each node is in: the containers it sits inside, or the domain and system cards
|
|
110
|
+
* connected to it with "contains"
|
|
111
|
+
*/
|
|
112
|
+
const getContainers = (nodes: Node[], edges: Edge[]) => {
|
|
113
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
114
|
+
const containers = new Map<string, { system?: string; domain?: string }>();
|
|
115
|
+
const add = (nodeId: string, container: Node) => {
|
|
116
|
+
const entry = containers.get(nodeId) ?? {};
|
|
117
|
+
if (isSystem(container)) entry.system ??= container.id;
|
|
118
|
+
else entry.domain ??= container.id;
|
|
119
|
+
containers.set(nodeId, entry);
|
|
120
|
+
};
|
|
121
|
+
for (const node of nodes) {
|
|
122
|
+
for (
|
|
123
|
+
let parent = node.parentId ? byId.get(node.parentId) : undefined;
|
|
124
|
+
parent;
|
|
125
|
+
parent = parent.parentId ? byId.get(parent.parentId) : undefined
|
|
126
|
+
) {
|
|
127
|
+
if (isContainer(parent)) add(node.id, parent);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const edge of edges) {
|
|
131
|
+
const container = byId.get(edge.source);
|
|
132
|
+
if (container && isContainer(container) && edge.label === 'contains') add(edge.target, container);
|
|
133
|
+
}
|
|
134
|
+
return containers;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Containers left with nothing inside them at a level become compact boxes (same id, so switching levels
|
|
139
|
+
* grows and shrinks them); containers with something inside are sized to fit by the layout
|
|
140
|
+
*/
|
|
141
|
+
const compactEmptyGroups = ({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) => {
|
|
142
|
+
const parents = new Set(nodes.map((node) => node.parentId).filter(Boolean));
|
|
143
|
+
return {
|
|
144
|
+
edges,
|
|
145
|
+
nodes: nodes.map((node) => {
|
|
146
|
+
if (!isGroupType(node.type)) return node;
|
|
147
|
+
if (parents.has(node.id)) return { ...node, width: undefined, height: undefined, measured: undefined };
|
|
148
|
+
return { ...node, ...COLLAPSED_GROUP_SIZE, measured: undefined };
|
|
149
|
+
}),
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** L2: messages and channels hidden, the nodes either side connected directly */
|
|
154
|
+
const level2 = (nodes: Node[], edges: Edge[]) =>
|
|
155
|
+
collapseGraph(nodes, edges, (node) => (isCarrier(node) ? { kind: 'carrier' } : { kind: 'node', id: node.id }));
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* L1 as EventCatalog's diagrams draw it: systems as cards (with how many services they hold), connected by plain
|
|
159
|
+
* edges labelled with the messages between them
|
|
160
|
+
*/
|
|
161
|
+
const asSystemsLevel = (
|
|
162
|
+
{ nodes, edges }: { nodes: Node[]; edges: Edge[] },
|
|
163
|
+
{ all, containers }: { all: Node[]; containers: ReturnType<typeof getContainers> }
|
|
164
|
+
) => {
|
|
165
|
+
const servicesIn = new Map<string, number>();
|
|
166
|
+
for (const node of all) {
|
|
167
|
+
const system = node.type === 'service' ? containers.get(node.id)?.system : undefined;
|
|
168
|
+
if (system) servicesIn.set(system, (servicesIn.get(system) ?? 0) + 1);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
nodes: nodes.map((node): Node => {
|
|
172
|
+
if (node.type !== GROUP_TYPES.system) return node;
|
|
173
|
+
const { id, parentId, position, data } = node;
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
type: 'system',
|
|
177
|
+
position,
|
|
178
|
+
...(parentId && { parentId }),
|
|
179
|
+
data: { mode: 'full', ...data, servicesCount: servicesIn.get(id) ?? 0 },
|
|
180
|
+
};
|
|
181
|
+
}),
|
|
182
|
+
edges: edges.map(({ type: _, data: __, ...edge }): Edge => ({ ...edge, type: 'default' })),
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** L1: services folded into their system (or domain); actors, external systems and loose services stay */
|
|
187
|
+
const level1 = (nodes: Node[], edges: Edge[]) => {
|
|
188
|
+
const containers = getContainers(nodes, edges);
|
|
189
|
+
const collapsed = collapseGraph(nodes, edges, (node) => {
|
|
190
|
+
const container = containers.get(node.id);
|
|
191
|
+
// Systems stay (inside their domain's container, if they're in one); everything else folds into its system or domain
|
|
192
|
+
if (isContainer(node) && (isSystem(node) || !container?.system)) return { kind: 'node', id: node.id };
|
|
193
|
+
if (container?.system) return { kind: 'node', id: container.system };
|
|
194
|
+
// Messages and channels connect the systems either side, even when they sit in a domain rather than a system
|
|
195
|
+
if (isCarrier(node)) return { kind: 'carrier' };
|
|
196
|
+
if (container?.domain) return { kind: 'node', id: container.domain };
|
|
197
|
+
if (node.type === 'data' || node.type === 'note') return { kind: 'drop' };
|
|
198
|
+
return { kind: 'node', id: node.id };
|
|
199
|
+
});
|
|
200
|
+
return asSystemsLevel(collapsed, { all: nodes, containers });
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** The graph for a level (not laid out: positions come from the layout) */
|
|
204
|
+
export const getLevelGraph = (nodes: Node[], edges: Edge[], level: Level) => {
|
|
205
|
+
if (level === 1) return compactEmptyGroups(level1(nodes, edges));
|
|
206
|
+
if (level === 2) return compactEmptyGroups(level2(nodes, edges));
|
|
207
|
+
return { nodes, edges };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Why a level can't be shown for this canvas, if it can't */
|
|
211
|
+
export const getLevelUnavailableReason = (nodes: Node[], level: Level): string | undefined => {
|
|
212
|
+
if (level === 1 && !nodes.some(isContainer))
|
|
213
|
+
return 'Not available: add a domain or system from the catalog to see how they relate';
|
|
214
|
+
if (level === 2 && !nodes.some(isCarrier)) return 'Not available: there are no messages or channels to hide';
|
|
215
|
+
return undefined;
|
|
216
|
+
};
|