@eventcatalog/core 3.11.0 → 3.12.1
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 +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-Q6BRAXMP.js → chunk-CFS3H2TO.js} +1 -1
- package/dist/{chunk-LHPQHOE5.js → chunk-GCMDVAHX.js} +1 -1
- package/dist/{chunk-744TUGLY.js → chunk-J3WNJQ6H.js} +1 -1
- package/dist/{chunk-JGYH3AAT.js → chunk-PPMGCQGC.js} +1 -1
- package/dist/{chunk-TE67QRWX.js → chunk-Y74D3CJA.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/MDX/NodeGraph/Edges/AnimatedMessageEdge.tsx +13 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeContent.tsx +294 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeNodeActions.tsx +92 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModePlaceholder.tsx +26 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/utils.ts +163 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusModeModal.tsx +99 -0
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.tsx +20 -42
- package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Entity.tsx +4 -1
- package/eventcatalog/src/components/MDX/NodeGraph/Nodes/MessageContextMenu.tsx +4 -1
- package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Service.tsx +4 -1
- package/eventcatalog/src/enterprise/custom-documentation/components/CustomDocsNav/components/NestedItem.tsx +52 -20
- package/eventcatalog/src/enterprise/custom-documentation/utils/custom-docs.ts +32 -3
- package/eventcatalog/src/enterprise/mcp/mcp-server.ts +3 -2
- package/eventcatalog/src/layouts/Footer.astro +4 -1
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +3 -1
- package/eventcatalog/tailwind.config.mjs +10 -0
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
log_build_default
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-GCMDVAHX.js";
|
|
4
|
+
import "../chunk-PPMGCQGC.js";
|
|
5
5
|
import "../chunk-4UVFXLPI.js";
|
|
6
|
-
import "../chunk-
|
|
6
|
+
import "../chunk-J3WNJQ6H.js";
|
|
7
7
|
import "../chunk-UPONRQSN.js";
|
|
8
8
|
export {
|
|
9
9
|
log_build_default as default
|
package/dist/constants.cjs
CHANGED
package/dist/constants.js
CHANGED
package/dist/eventcatalog.cjs
CHANGED
|
@@ -109,7 +109,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
|
|
|
109
109
|
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
110
110
|
|
|
111
111
|
// package.json
|
|
112
|
-
var version = "3.
|
|
112
|
+
var version = "3.12.1";
|
|
113
113
|
|
|
114
114
|
// src/constants.ts
|
|
115
115
|
var VERSION = version;
|
package/dist/eventcatalog.js
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
} from "./chunk-PLNJC7NZ.js";
|
|
7
7
|
import {
|
|
8
8
|
log_build_default
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import "./chunk-
|
|
9
|
+
} from "./chunk-GCMDVAHX.js";
|
|
10
|
+
import "./chunk-PPMGCQGC.js";
|
|
11
11
|
import "./chunk-4UVFXLPI.js";
|
|
12
12
|
import {
|
|
13
13
|
runMigrations
|
|
@@ -22,13 +22,13 @@ import {
|
|
|
22
22
|
} from "./chunk-5VBIXL6C.js";
|
|
23
23
|
import {
|
|
24
24
|
generate
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-CFS3H2TO.js";
|
|
26
26
|
import {
|
|
27
27
|
logger
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-Y74D3CJA.js";
|
|
29
29
|
import {
|
|
30
30
|
VERSION
|
|
31
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-J3WNJQ6H.js";
|
|
32
32
|
import "./chunk-UPONRQSN.js";
|
|
33
33
|
|
|
34
34
|
// src/eventcatalog.ts
|
package/dist/generate.cjs
CHANGED
package/dist/generate.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
generate
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-CFS3H2TO.js";
|
|
4
|
+
import "./chunk-Y74D3CJA.js";
|
|
5
|
+
import "./chunk-J3WNJQ6H.js";
|
|
6
6
|
import "./chunk-UPONRQSN.js";
|
|
7
7
|
export {
|
|
8
8
|
generate
|
package/dist/utils/cli-logger.js
CHANGED
|
@@ -81,6 +81,19 @@ const AnimatedMessageEdge = ({
|
|
|
81
81
|
{/* <g className={`z-30 ${opacity === 1 ? 'opacity-100' : 'opacity-10'}`}>
|
|
82
82
|
</g> */}
|
|
83
83
|
<g>
|
|
84
|
+
{/* Background rect for label */}
|
|
85
|
+
{label && (
|
|
86
|
+
<rect
|
|
87
|
+
x={labelX - 30}
|
|
88
|
+
y={labelY - lines.length * 6}
|
|
89
|
+
width={60}
|
|
90
|
+
height={lines.length * 14}
|
|
91
|
+
fill="white"
|
|
92
|
+
fillOpacity={0.3}
|
|
93
|
+
rx={4}
|
|
94
|
+
ry={4}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
84
97
|
{/* Text element */}
|
|
85
98
|
<text x={labelX} y={labelY} textAnchor="middle" dominantBaseline="middle" fontSize="10px" pointerEvents="none">
|
|
86
99
|
{lines.map((line, i) => (
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import React, { useMemo, useCallback, useEffect, useState, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Background,
|
|
5
|
+
Controls,
|
|
6
|
+
useReactFlow,
|
|
7
|
+
useNodesState,
|
|
8
|
+
useEdgesState,
|
|
9
|
+
type Node,
|
|
10
|
+
type Edge,
|
|
11
|
+
type NodeTypes,
|
|
12
|
+
type EdgeTypes,
|
|
13
|
+
} from '@xyflow/react';
|
|
14
|
+
import { getConnectedNodes, getNodeDisplayInfo } from './utils';
|
|
15
|
+
import FocusModeNodeActions from './FocusModeNodeActions';
|
|
16
|
+
import FocusModePlaceholder from './FocusModePlaceholder';
|
|
17
|
+
|
|
18
|
+
interface FocusModeContentProps {
|
|
19
|
+
centerNodeId: string;
|
|
20
|
+
nodes: Node[];
|
|
21
|
+
edges: Edge[];
|
|
22
|
+
nodeTypes: NodeTypes;
|
|
23
|
+
edgeTypes: EdgeTypes;
|
|
24
|
+
onSwitchCenter: (newCenterNodeId: string, direction: 'left' | 'right') => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HORIZONTAL_SPACING = 450;
|
|
28
|
+
const VERTICAL_SPACING = 200;
|
|
29
|
+
const SLIDE_DURATION = 300;
|
|
30
|
+
|
|
31
|
+
const FocusModeContent: React.FC<FocusModeContentProps> = ({
|
|
32
|
+
centerNodeId,
|
|
33
|
+
nodes: allNodes,
|
|
34
|
+
edges: allEdges,
|
|
35
|
+
nodeTypes,
|
|
36
|
+
edgeTypes,
|
|
37
|
+
onSwitchCenter,
|
|
38
|
+
}) => {
|
|
39
|
+
const { fitView } = useReactFlow();
|
|
40
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
41
|
+
const [needsFitView, setNeedsFitView] = useState(false);
|
|
42
|
+
const [hoveredEdgeId, setHoveredEdgeId] = useState<string | null>(null);
|
|
43
|
+
const reactFlowInitialized = useRef(false);
|
|
44
|
+
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
45
|
+
|
|
46
|
+
// Cleanup timeout on unmount
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
return () => {
|
|
49
|
+
if (animationTimeoutRef.current) {
|
|
50
|
+
clearTimeout(animationTimeoutRef.current);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
// Calculate focused nodes and edges with positions
|
|
56
|
+
const calculateFocusedGraph = useCallback(
|
|
57
|
+
(centerId: string) => {
|
|
58
|
+
const centerNode = allNodes.find((n) => n.id === centerId);
|
|
59
|
+
if (!centerNode) {
|
|
60
|
+
return { nodes: [], edges: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { leftNodes, rightNodes } = getConnectedNodes(centerId, allNodes, allEdges);
|
|
64
|
+
const centerNodeInfo = getNodeDisplayInfo(centerNode);
|
|
65
|
+
const positionedNodes: Node[] = [];
|
|
66
|
+
|
|
67
|
+
// Center node at origin
|
|
68
|
+
positionedNodes.push({
|
|
69
|
+
...centerNode,
|
|
70
|
+
position: { x: 0, y: 0 },
|
|
71
|
+
style: { ...centerNode.style, opacity: 1 },
|
|
72
|
+
data: { ...centerNode.data, isFocusCenter: true },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Left nodes (incoming)
|
|
76
|
+
leftNodes.forEach((node, index) => {
|
|
77
|
+
const yOffset = (index - (leftNodes.length - 1) / 2) * VERTICAL_SPACING;
|
|
78
|
+
positionedNodes.push({
|
|
79
|
+
...node,
|
|
80
|
+
position: { x: -HORIZONTAL_SPACING, y: yOffset },
|
|
81
|
+
style: { ...node.style, opacity: 1 },
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Right nodes (outgoing)
|
|
86
|
+
rightNodes.forEach((node, index) => {
|
|
87
|
+
const yOffset = (index - (rightNodes.length - 1) / 2) * VERTICAL_SPACING;
|
|
88
|
+
positionedNodes.push({
|
|
89
|
+
...node,
|
|
90
|
+
position: { x: HORIZONTAL_SPACING, y: yOffset },
|
|
91
|
+
style: { ...node.style, opacity: 1 },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Add placeholder nodes if no connections exist
|
|
96
|
+
if (leftNodes.length === 0) {
|
|
97
|
+
positionedNodes.push({
|
|
98
|
+
id: '__placeholder-left__',
|
|
99
|
+
type: 'placeholder',
|
|
100
|
+
position: { x: -HORIZONTAL_SPACING, y: 0 },
|
|
101
|
+
data: { label: `No inputs found for "${centerNodeInfo.name}" in this diagram`, side: 'left' },
|
|
102
|
+
draggable: false,
|
|
103
|
+
selectable: false,
|
|
104
|
+
} as Node);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (rightNodes.length === 0) {
|
|
108
|
+
positionedNodes.push({
|
|
109
|
+
id: '__placeholder-right__',
|
|
110
|
+
type: 'placeholder',
|
|
111
|
+
position: { x: HORIZONTAL_SPACING, y: 0 },
|
|
112
|
+
data: { label: `No outputs found for "${centerNodeInfo.name}" in this diagram`, side: 'right' },
|
|
113
|
+
draggable: false,
|
|
114
|
+
selectable: false,
|
|
115
|
+
} as Node);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Filter edges - only show edges connected to center node
|
|
119
|
+
const focusedNodeIds = new Set(positionedNodes.map((n) => n.id));
|
|
120
|
+
const filteredEdges = allEdges
|
|
121
|
+
.filter((edge) => {
|
|
122
|
+
// Only include edges where center node is either source or target
|
|
123
|
+
const connectsToCenter = edge.source === centerId || edge.target === centerId;
|
|
124
|
+
// And the other end is in our focused nodes
|
|
125
|
+
const otherEndInFocus = focusedNodeIds.has(edge.source) && focusedNodeIds.has(edge.target);
|
|
126
|
+
return connectsToCenter && otherEndInFocus;
|
|
127
|
+
})
|
|
128
|
+
.map((edge) => ({
|
|
129
|
+
...edge,
|
|
130
|
+
style: { ...edge.style, opacity: 1 },
|
|
131
|
+
labelStyle: { ...edge.labelStyle, opacity: 1 },
|
|
132
|
+
data: { ...edge.data, opacity: 1, animated: false },
|
|
133
|
+
animated: false,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
return { nodes: positionedNodes, edges: filteredEdges };
|
|
137
|
+
},
|
|
138
|
+
[allNodes, allEdges]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Initial graph
|
|
142
|
+
const initialGraph = useMemo(() => calculateFocusedGraph(centerNodeId), [centerNodeId, calculateFocusedGraph]);
|
|
143
|
+
|
|
144
|
+
const [displayNodes, setDisplayNodes] = useNodesState(initialGraph.nodes);
|
|
145
|
+
const [displayEdges, setDisplayEdges] = useEdgesState(initialGraph.edges);
|
|
146
|
+
|
|
147
|
+
// Update when centerNodeId changes externally
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const { nodes, edges } = calculateFocusedGraph(centerNodeId);
|
|
150
|
+
setDisplayNodes(nodes);
|
|
151
|
+
setDisplayEdges(edges);
|
|
152
|
+
setNeedsFitView(true);
|
|
153
|
+
}, [centerNodeId, calculateFocusedGraph, setDisplayNodes, setDisplayEdges]);
|
|
154
|
+
|
|
155
|
+
// FitView when needed - triggered after nodes are updated
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (needsFitView && reactFlowInitialized.current) {
|
|
158
|
+
// Wait for nodes to be fully rendered, then fit view with animation
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
fitView({ padding: 0.2, duration: 400 });
|
|
161
|
+
setNeedsFitView(false);
|
|
162
|
+
}, 150);
|
|
163
|
+
return () => clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
}, [needsFitView, displayNodes, fitView]);
|
|
166
|
+
|
|
167
|
+
// Handle ReactFlow initialization
|
|
168
|
+
const handleInit = useCallback(() => {
|
|
169
|
+
reactFlowInitialized.current = true;
|
|
170
|
+
// Wait for nodes to be fully rendered, then fit view with animation
|
|
171
|
+
setTimeout(() => {
|
|
172
|
+
fitView({ padding: 0.2, duration: 400 });
|
|
173
|
+
}, 150);
|
|
174
|
+
}, [fitView]);
|
|
175
|
+
|
|
176
|
+
// Handle switching to a new center node with animation
|
|
177
|
+
const handleSwitchNode = useCallback(
|
|
178
|
+
(nodeId: string, direction: 'left' | 'right') => {
|
|
179
|
+
if (nodeId === centerNodeId || isAnimating) return;
|
|
180
|
+
|
|
181
|
+
setIsAnimating(true);
|
|
182
|
+
|
|
183
|
+
// Animate: clicked node slides to center, current center hides
|
|
184
|
+
setDisplayNodes((currentNodes) =>
|
|
185
|
+
currentNodes.map((node) => {
|
|
186
|
+
if (node.id === nodeId) {
|
|
187
|
+
// Clicked node slides to center
|
|
188
|
+
return {
|
|
189
|
+
...node,
|
|
190
|
+
position: { x: 0, y: 0 },
|
|
191
|
+
style: { ...node.style, transition: `all ${SLIDE_DURATION}ms ease-out` },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (node.id === centerNodeId) {
|
|
195
|
+
// Current center node hides
|
|
196
|
+
return {
|
|
197
|
+
...node,
|
|
198
|
+
style: { ...node.style, opacity: 0, transition: `opacity ${SLIDE_DURATION}ms ease-out` },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return node;
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// After slide completes, switch to new center
|
|
206
|
+
animationTimeoutRef.current = setTimeout(() => {
|
|
207
|
+
onSwitchCenter(nodeId, direction);
|
|
208
|
+
setIsAnimating(false);
|
|
209
|
+
}, SLIDE_DURATION);
|
|
210
|
+
},
|
|
211
|
+
[centerNodeId, isAnimating, setDisplayNodes, onSwitchCenter]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Handle node click with animation
|
|
215
|
+
const handleNodeClick = useCallback(
|
|
216
|
+
(_: React.MouseEvent, clickedNode: Node) => {
|
|
217
|
+
if (clickedNode.id === centerNodeId || isAnimating) return;
|
|
218
|
+
const direction = (clickedNode.position?.x ?? 0) < 0 ? 'left' : 'right';
|
|
219
|
+
handleSwitchNode(clickedNode.id, direction);
|
|
220
|
+
},
|
|
221
|
+
[centerNodeId, isAnimating, handleSwitchNode]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Handle edge hover for animation
|
|
225
|
+
const handleEdgeMouseEnter = useCallback((_: React.MouseEvent, edge: Edge) => {
|
|
226
|
+
setHoveredEdgeId(edge.id);
|
|
227
|
+
}, []);
|
|
228
|
+
|
|
229
|
+
const handleEdgeMouseLeave = useCallback(() => {
|
|
230
|
+
setHoveredEdgeId(null);
|
|
231
|
+
}, []);
|
|
232
|
+
|
|
233
|
+
// Apply hover animation to edges - use ReactFlow's built-in animated property
|
|
234
|
+
const edgesWithHover = useMemo(() => {
|
|
235
|
+
return displayEdges.map((edge) => {
|
|
236
|
+
if (edge.id === hoveredEdgeId) {
|
|
237
|
+
return {
|
|
238
|
+
...edge,
|
|
239
|
+
animated: true,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return edge;
|
|
243
|
+
});
|
|
244
|
+
}, [displayEdges, hoveredEdgeId]);
|
|
245
|
+
|
|
246
|
+
// Merge nodeTypes with placeholder
|
|
247
|
+
const mergedNodeTypes = useMemo(
|
|
248
|
+
() => ({
|
|
249
|
+
...nodeTypes,
|
|
250
|
+
placeholder: FocusModePlaceholder,
|
|
251
|
+
}),
|
|
252
|
+
[nodeTypes]
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (displayNodes.length === 0) {
|
|
256
|
+
return <div className="flex items-center justify-center h-full text-[rgb(var(--ec-page-text-muted))]">Node not found</div>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div className="h-full w-full focus-mode-container">
|
|
261
|
+
<ReactFlow
|
|
262
|
+
nodes={displayNodes}
|
|
263
|
+
edges={edgesWithHover}
|
|
264
|
+
nodeTypes={mergedNodeTypes}
|
|
265
|
+
edgeTypes={edgeTypes}
|
|
266
|
+
onNodeClick={handleNodeClick}
|
|
267
|
+
onEdgeMouseEnter={handleEdgeMouseEnter}
|
|
268
|
+
onEdgeMouseLeave={handleEdgeMouseLeave}
|
|
269
|
+
onInit={handleInit}
|
|
270
|
+
proOptions={{ hideAttribution: true }}
|
|
271
|
+
nodesDraggable={true}
|
|
272
|
+
nodesConnectable={false}
|
|
273
|
+
elementsSelectable={true}
|
|
274
|
+
panOnDrag={true}
|
|
275
|
+
zoomOnScroll={true}
|
|
276
|
+
minZoom={0.3}
|
|
277
|
+
maxZoom={2}
|
|
278
|
+
>
|
|
279
|
+
<Background color="rgb(var(--ec-page-border))" gap={20} />
|
|
280
|
+
<Controls showInteractive={false} />
|
|
281
|
+
{displayNodes.map((node, index) => (
|
|
282
|
+
<FocusModeNodeActions
|
|
283
|
+
key={`actions-${node.id}-${index}`}
|
|
284
|
+
node={node}
|
|
285
|
+
isCenter={node.id === centerNodeId}
|
|
286
|
+
onSwitch={handleSwitchNode}
|
|
287
|
+
/>
|
|
288
|
+
))}
|
|
289
|
+
</ReactFlow>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export default FocusModeContent;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { NodeToolbar, Position, useViewport, type Node } from '@xyflow/react';
|
|
3
|
+
import { ArrowRightLeft, FileText } from 'lucide-react';
|
|
4
|
+
import { getNodeDocUrl } from './utils';
|
|
5
|
+
import { buildUrl } from '@utils/url-builder';
|
|
6
|
+
|
|
7
|
+
interface FocusModeNodeActionsProps {
|
|
8
|
+
node: Node;
|
|
9
|
+
isCenter: boolean;
|
|
10
|
+
onSwitch: (nodeId: string, direction: 'left' | 'right') => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const FocusModeNodeActions: React.FC<FocusModeNodeActionsProps> = ({ node, isCenter, onSwitch }) => {
|
|
14
|
+
const { zoom } = useViewport();
|
|
15
|
+
|
|
16
|
+
// Don't show actions for placeholder nodes
|
|
17
|
+
if (node.type === 'placeholder') return null;
|
|
18
|
+
|
|
19
|
+
const docUrl = getNodeDocUrl(node);
|
|
20
|
+
const direction = (node.position?.x ?? 0) < 0 ? 'left' : 'right';
|
|
21
|
+
|
|
22
|
+
// Scale sizes based on zoom (inverse relationship - smaller when zoomed out)
|
|
23
|
+
const baseButtonSize = 32;
|
|
24
|
+
const baseIconSize = 16;
|
|
25
|
+
const scaleFactor = Math.max(0.6, Math.min(1, zoom));
|
|
26
|
+
const buttonSize = Math.round(baseButtonSize * scaleFactor);
|
|
27
|
+
const iconSize = Math.round(baseIconSize * scaleFactor);
|
|
28
|
+
|
|
29
|
+
const handleSwitch = (e: React.MouseEvent) => {
|
|
30
|
+
e.stopPropagation();
|
|
31
|
+
onSwitch(node.id, direction);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleDocClick = (e: React.MouseEvent) => {
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
if (docUrl) {
|
|
37
|
+
window.location.href = buildUrl(docUrl);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Center node only shows docs icon (if available)
|
|
42
|
+
if (isCenter) {
|
|
43
|
+
if (!docUrl) return null;
|
|
44
|
+
return (
|
|
45
|
+
<NodeToolbar nodeId={node.id} position={Position.Bottom} isVisible={true} offset={-16}>
|
|
46
|
+
<div
|
|
47
|
+
className="flex items-center gap-1 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-lg shadow-md"
|
|
48
|
+
style={{ padding: Math.round(4 * scaleFactor) }}
|
|
49
|
+
>
|
|
50
|
+
<button
|
|
51
|
+
onClick={handleDocClick}
|
|
52
|
+
className="flex items-center justify-center rounded-md text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))] transition-colors"
|
|
53
|
+
style={{ width: buttonSize, height: buttonSize }}
|
|
54
|
+
title="View documentation"
|
|
55
|
+
>
|
|
56
|
+
<FileText style={{ width: iconSize, height: iconSize }} />
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</NodeToolbar>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<NodeToolbar nodeId={node.id} position={Position.Bottom} isVisible={true} offset={-16}>
|
|
65
|
+
<div
|
|
66
|
+
className="flex items-center gap-1 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-lg shadow-md"
|
|
67
|
+
style={{ padding: Math.round(4 * scaleFactor) }}
|
|
68
|
+
>
|
|
69
|
+
{docUrl && (
|
|
70
|
+
<button
|
|
71
|
+
onClick={handleDocClick}
|
|
72
|
+
className="flex items-center justify-center rounded-md text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))] transition-colors"
|
|
73
|
+
style={{ width: buttonSize, height: buttonSize }}
|
|
74
|
+
title="View documentation"
|
|
75
|
+
>
|
|
76
|
+
<FileText style={{ width: iconSize, height: iconSize }} />
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
<button
|
|
80
|
+
onClick={handleSwitch}
|
|
81
|
+
className="flex items-center justify-center rounded-md text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))] transition-colors"
|
|
82
|
+
style={{ width: buttonSize, height: buttonSize }}
|
|
83
|
+
title="Focus on this node"
|
|
84
|
+
>
|
|
85
|
+
<ArrowRightLeft style={{ width: iconSize, height: iconSize }} />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</NodeToolbar>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default FocusModeNodeActions;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Handle, Position } from '@xyflow/react';
|
|
3
|
+
|
|
4
|
+
interface FocusModePlaceholderProps {
|
|
5
|
+
data: {
|
|
6
|
+
label: string;
|
|
7
|
+
side: 'left' | 'right';
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FocusModePlaceholder: React.FC<FocusModePlaceholderProps> = ({ data }) => {
|
|
12
|
+
const { label, side } = data;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className="px-4 py-4 rounded-lg border-2 border-dashed border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg)/0.5)] max-w-[280px] flex items-center justify-center"
|
|
17
|
+
style={{ opacity: 0.6, minHeight: '130px' }}
|
|
18
|
+
>
|
|
19
|
+
{side === 'right' && <Handle type="target" position={Position.Left} style={{ visibility: 'hidden' }} />}
|
|
20
|
+
<div className="text-center text-sm text-[rgb(var(--ec-page-text-muted))] italic">{label}</div>
|
|
21
|
+
{side === 'left' && <Handle type="source" position={Position.Right} style={{ visibility: 'hidden' }} />}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default FocusModePlaceholder;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Node, Edge } from '@xyflow/react';
|
|
2
|
+
|
|
3
|
+
export interface NodeDisplayInfo {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ConnectedNodes {
|
|
12
|
+
leftNodes: Node[];
|
|
13
|
+
rightNodes: Node[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const NODE_COLOR_CLASSES: Record<string, string> = {
|
|
17
|
+
events: 'bg-orange-600',
|
|
18
|
+
services: 'bg-pink-600',
|
|
19
|
+
flows: 'bg-teal-600',
|
|
20
|
+
commands: 'bg-blue-600',
|
|
21
|
+
queries: 'bg-green-600',
|
|
22
|
+
channels: 'bg-gray-600',
|
|
23
|
+
externalSystem: 'bg-pink-600',
|
|
24
|
+
actor: 'bg-yellow-500',
|
|
25
|
+
step: 'bg-gray-700',
|
|
26
|
+
data: 'bg-blue-600',
|
|
27
|
+
'data-products': 'bg-indigo-600',
|
|
28
|
+
domains: 'bg-yellow-600',
|
|
29
|
+
entities: 'bg-purple-600',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const NODE_TYPE_LABELS: Record<string, string> = {
|
|
33
|
+
events: 'Event',
|
|
34
|
+
services: 'Service',
|
|
35
|
+
flows: 'Flow',
|
|
36
|
+
commands: 'Command',
|
|
37
|
+
queries: 'Query',
|
|
38
|
+
channels: 'Channel',
|
|
39
|
+
externalSystem: 'External System',
|
|
40
|
+
actor: 'Actor',
|
|
41
|
+
step: 'Step',
|
|
42
|
+
data: 'Data',
|
|
43
|
+
'data-products': 'Data Product',
|
|
44
|
+
domains: 'Domain',
|
|
45
|
+
entities: 'Entity',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get connected nodes for a given center node
|
|
50
|
+
* Left nodes: incoming edges (they send TO this node)
|
|
51
|
+
* Right nodes: outgoing edges (this node sends TO them)
|
|
52
|
+
*/
|
|
53
|
+
export function getConnectedNodes(centerNodeId: string, nodes: Node[], edges: Edge[]): ConnectedNodes {
|
|
54
|
+
const leftIds = new Set<string>();
|
|
55
|
+
const rightIds = new Set<string>();
|
|
56
|
+
|
|
57
|
+
edges.forEach((edge) => {
|
|
58
|
+
if (edge.target === centerNodeId) {
|
|
59
|
+
leftIds.add(edge.source);
|
|
60
|
+
}
|
|
61
|
+
if (edge.source === centerNodeId) {
|
|
62
|
+
rightIds.add(edge.target);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
leftNodes: nodes.filter((n) => leftIds.has(n.id)),
|
|
68
|
+
rightNodes: nodes.filter((n) => rightIds.has(n.id)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Entity keys that follow the standard data structure pattern
|
|
73
|
+
const ENTITY_KEYS = ['service', 'message', 'flow', 'channel', 'domain', 'entity', 'dataProduct'] as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract display information from a ReactFlow node
|
|
77
|
+
*/
|
|
78
|
+
export function getNodeDisplayInfo(node: Node): NodeDisplayInfo {
|
|
79
|
+
const nodeType = node.type || 'unknown';
|
|
80
|
+
const data = node.data as any;
|
|
81
|
+
|
|
82
|
+
// Find the entity in data using standard keys
|
|
83
|
+
const entityKey = ENTITY_KEYS.find((key) => data[key]);
|
|
84
|
+
const entity = entityKey ? data[entityKey] : null;
|
|
85
|
+
|
|
86
|
+
const name = entity?.data?.name || entity?.id || data.label || data.name || node.id;
|
|
87
|
+
const version = entity?.data?.version || entity?.version || data.version || '';
|
|
88
|
+
const description = entity?.data?.summary || entity?.data?.description || data.summary || data.description || '';
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
id: node.id,
|
|
92
|
+
name,
|
|
93
|
+
type: nodeType,
|
|
94
|
+
version,
|
|
95
|
+
description: description ? truncateDescription(description, 100) : undefined,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Truncate description to a max length
|
|
101
|
+
*/
|
|
102
|
+
function truncateDescription(text: string, maxLength: number): string {
|
|
103
|
+
if (text.length <= maxLength) return text;
|
|
104
|
+
return text.slice(0, maxLength).trim() + '...';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get the color class for a node type
|
|
109
|
+
*/
|
|
110
|
+
export function getNodeColorClass(nodeType: string): string {
|
|
111
|
+
return NODE_COLOR_CLASSES[nodeType] || 'bg-gray-500';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get the display label for a node type
|
|
116
|
+
*/
|
|
117
|
+
export function getNodeTypeLabel(nodeType: string): string {
|
|
118
|
+
return NODE_TYPE_LABELS[nodeType] || nodeType;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Mapping from entity key to doc path
|
|
122
|
+
const DOC_PATH_MAP: Record<string, string> = {
|
|
123
|
+
service: 'services',
|
|
124
|
+
flow: 'flows',
|
|
125
|
+
channel: 'channels',
|
|
126
|
+
domain: 'domains',
|
|
127
|
+
entity: 'entities',
|
|
128
|
+
dataProduct: 'data-products',
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get the documentation URL for a node
|
|
133
|
+
*/
|
|
134
|
+
export function getNodeDocUrl(node: Node): string | null {
|
|
135
|
+
const nodeType = node.type || 'unknown';
|
|
136
|
+
const data = node.data as any;
|
|
137
|
+
|
|
138
|
+
// Handle message type separately due to type mapping
|
|
139
|
+
if (data.message) {
|
|
140
|
+
const id = data.message.data?.id || data.message.id || '';
|
|
141
|
+
const version = data.message.data?.version || data.message.version || '';
|
|
142
|
+
const collectionType = nodeType === 'events' ? 'events' : nodeType === 'commands' ? 'commands' : 'queries';
|
|
143
|
+
return id && version ? `/docs/${collectionType}/${id}/${version}` : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Handle data/container nodes with nested data.data structure
|
|
147
|
+
if (data.data && nodeType === 'data') {
|
|
148
|
+
const id = data.data.id || '';
|
|
149
|
+
const version = data.data.version || '';
|
|
150
|
+
return id && version ? `/docs/containers/${id}/${version}` : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Handle standard entity types
|
|
154
|
+
for (const [key, path] of Object.entries(DOC_PATH_MAP)) {
|
|
155
|
+
if (data[key]) {
|
|
156
|
+
const id = data[key].data?.id || data[key].id || '';
|
|
157
|
+
const version = data[key].data?.version || data[key].version || '';
|
|
158
|
+
return id && version ? `/docs/${path}/${id}/${version}` : null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import * as Dialog from '@radix-ui/react-dialog';
|
|
3
|
+
import { XIcon, FocusIcon } from 'lucide-react';
|
|
4
|
+
import { ReactFlowProvider, type Node, type Edge, type NodeTypes, type EdgeTypes } from '@xyflow/react';
|
|
5
|
+
import FocusModeContent from './FocusMode/FocusModeContent';
|
|
6
|
+
import { getNodeDisplayInfo } from './FocusMode/utils';
|
|
7
|
+
|
|
8
|
+
interface FocusModeModalProps {
|
|
9
|
+
isOpen: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
initialNodeId: string | null;
|
|
12
|
+
nodes: Node[];
|
|
13
|
+
edges: Edge[];
|
|
14
|
+
nodeTypes: NodeTypes;
|
|
15
|
+
edgeTypes: EdgeTypes;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FocusModeModal: React.FC<FocusModeModalProps> = ({
|
|
19
|
+
isOpen,
|
|
20
|
+
onClose,
|
|
21
|
+
initialNodeId,
|
|
22
|
+
nodes,
|
|
23
|
+
edges,
|
|
24
|
+
nodeTypes,
|
|
25
|
+
edgeTypes,
|
|
26
|
+
}) => {
|
|
27
|
+
const [centerNodeId, setCenterNodeId] = useState<string | null>(initialNodeId);
|
|
28
|
+
|
|
29
|
+
// Reset center node when modal opens with new initial node
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (isOpen && initialNodeId) {
|
|
32
|
+
setCenterNodeId(initialNodeId);
|
|
33
|
+
}
|
|
34
|
+
}, [isOpen, initialNodeId]);
|
|
35
|
+
|
|
36
|
+
const handleSwitchCenter = useCallback((newCenterNodeId: string, _direction: 'left' | 'right') => {
|
|
37
|
+
setCenterNodeId(newCenterNodeId);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
// Get center node info for title
|
|
41
|
+
const centerNode = centerNodeId ? nodes.find((n) => n.id === centerNodeId) : null;
|
|
42
|
+
const centerNodeInfo = centerNode ? getNodeDisplayInfo(centerNode) : null;
|
|
43
|
+
|
|
44
|
+
if (!centerNodeId) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
50
|
+
<Dialog.Portal container={typeof document !== 'undefined' ? document.body : undefined}>
|
|
51
|
+
<div className="fixed inset-0 z-[99999]" style={{ isolation: 'isolate' }}>
|
|
52
|
+
<Dialog.Overlay className="fixed inset-0 bg-black/70 data-[state=open]:animate-overlayShow" />
|
|
53
|
+
<Dialog.Content className="fixed inset-4 md:inset-8 lg:inset-12 rounded-lg bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] shadow-xl focus:outline-none data-[state=open]:animate-contentShow flex flex-col overflow-hidden">
|
|
54
|
+
{/* Header */}
|
|
55
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-[rgb(var(--ec-page-border))] flex-shrink-0">
|
|
56
|
+
<div className="flex items-center gap-3">
|
|
57
|
+
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-[rgb(var(--ec-accent-subtle))]">
|
|
58
|
+
<FocusIcon className="w-5 h-5 text-[rgb(var(--ec-accent))]" />
|
|
59
|
+
</div>
|
|
60
|
+
<div>
|
|
61
|
+
<Dialog.Title className="text-lg font-semibold text-[rgb(var(--ec-page-text))]">Focus Mode</Dialog.Title>
|
|
62
|
+
<Dialog.Description className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
63
|
+
{centerNodeInfo
|
|
64
|
+
? `Exploring: ${centerNodeInfo.name} - Click on connected nodes to navigate`
|
|
65
|
+
: 'Explore node connections'}
|
|
66
|
+
</Dialog.Description>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<Dialog.Close asChild>
|
|
70
|
+
<button
|
|
71
|
+
className="flex items-center justify-center w-10 h-10 rounded-lg text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover,var(--ec-page-border)/0.5))] transition-colors"
|
|
72
|
+
aria-label="Close"
|
|
73
|
+
>
|
|
74
|
+
<XIcon className="w-5 h-5" />
|
|
75
|
+
</button>
|
|
76
|
+
</Dialog.Close>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Content */}
|
|
80
|
+
<div className="flex-1 overflow-hidden">
|
|
81
|
+
<ReactFlowProvider>
|
|
82
|
+
<FocusModeContent
|
|
83
|
+
centerNodeId={centerNodeId}
|
|
84
|
+
nodes={nodes}
|
|
85
|
+
edges={edges}
|
|
86
|
+
nodeTypes={nodeTypes}
|
|
87
|
+
edgeTypes={edgeTypes}
|
|
88
|
+
onSwitchCenter={handleSwitchCenter}
|
|
89
|
+
/>
|
|
90
|
+
</ReactFlowProvider>
|
|
91
|
+
</div>
|
|
92
|
+
</Dialog.Content>
|
|
93
|
+
</div>
|
|
94
|
+
</Dialog.Portal>
|
|
95
|
+
</Dialog.Root>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default FocusModeModal;
|
|
@@ -54,6 +54,7 @@ import { useEventCatalogVisualiser } from 'src/hooks/eventcatalog-visualizer';
|
|
|
54
54
|
import VisualiserSearch, { type VisualiserSearchRef } from './VisualiserSearch';
|
|
55
55
|
import StepWalkthrough from './StepWalkthrough';
|
|
56
56
|
import StudioModal from './StudioModal';
|
|
57
|
+
import FocusModeModal from './FocusModeModal';
|
|
57
58
|
import MermaidView from './MermaidView';
|
|
58
59
|
import VisualizerDropdownContent from './VisualizerDropdownContent';
|
|
59
60
|
import { convertToMermaid } from '@utils/node-graphs/export-mermaid';
|
|
@@ -165,6 +166,8 @@ const NodeGraphBuilder = ({
|
|
|
165
166
|
const [isSavingLayout, setIsSavingLayout] = useState(false);
|
|
166
167
|
const initialPositionsRef = useRef<Record<string, { x: number; y: number }>>({});
|
|
167
168
|
// const [isStudioModalOpen, setIsStudioModalOpen] = useState(false);
|
|
169
|
+
const [focusModeOpen, setFocusModeOpen] = useState(false);
|
|
170
|
+
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
|
|
168
171
|
|
|
169
172
|
// Check if there are channels to determine if we need the visualizer functionality
|
|
170
173
|
const hasChannels = useMemo(() => initialNodes.some((node: any) => node.type === 'channels'), [initialNodes]);
|
|
@@ -251,50 +254,16 @@ const NodeGraphBuilder = ({
|
|
|
251
254
|
return;
|
|
252
255
|
}
|
|
253
256
|
|
|
254
|
-
|
|
257
|
+
// Disable focus mode for flow and entity visualizations
|
|
258
|
+
const isFlow = edges.some((edge: Edge) => edge.type === 'flow-edge');
|
|
259
|
+
const isEntityVisualizer = nodes.some((n: Node) => n.type === 'entities');
|
|
260
|
+
if (isFlow || isEntityVisualizer) return;
|
|
255
261
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const updatedEdges = edges.map((edge) => {
|
|
260
|
-
if (edge.source === node.id || edge.target === node.id) {
|
|
261
|
-
connectedNodeIds.add(edge.source);
|
|
262
|
-
connectedNodeIds.add(edge.target);
|
|
263
|
-
return {
|
|
264
|
-
...edge,
|
|
265
|
-
data: { ...edge.data, opacity: 1, animated: animateMessages },
|
|
266
|
-
style: { ...edge.style, opacity: 1 },
|
|
267
|
-
labelStyle: { ...edge.labelStyle, opacity: 1 },
|
|
268
|
-
animated: true,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
return {
|
|
272
|
-
...edge,
|
|
273
|
-
data: { ...edge.data, opacity: 0.1, animated: animateMessages },
|
|
274
|
-
style: { ...edge.style, opacity: 0.1 },
|
|
275
|
-
labelStyle: { ...edge.labelStyle, opacity: 0.1 },
|
|
276
|
-
animated: animateMessages,
|
|
277
|
-
};
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const updatedNodes = nodes.map((n) => {
|
|
281
|
-
if (connectedNodeIds.has(n.id)) {
|
|
282
|
-
return { ...n, style: { ...n.style, opacity: 1 } };
|
|
283
|
-
}
|
|
284
|
-
return { ...n, style: { ...n.style, opacity: 0.1 } };
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
setNodes(updatedNodes);
|
|
288
|
-
setEdges(updatedEdges);
|
|
289
|
-
|
|
290
|
-
// Fit the clicked node and its connected nodes into view
|
|
291
|
-
fitView({
|
|
292
|
-
padding: 0.2,
|
|
293
|
-
duration: 800,
|
|
294
|
-
nodes: updatedNodes.filter((n) => connectedNodeIds.has(n.id)),
|
|
295
|
-
});
|
|
262
|
+
// Open focus mode modal
|
|
263
|
+
setFocusedNodeId(node.id);
|
|
264
|
+
setFocusModeOpen(true);
|
|
296
265
|
},
|
|
297
|
-
[
|
|
266
|
+
[linksToVisualiser, edges, nodes]
|
|
298
267
|
);
|
|
299
268
|
|
|
300
269
|
const toggleAnimateMessages = () => {
|
|
@@ -1007,6 +976,15 @@ const NodeGraphBuilder = ({
|
|
|
1007
976
|
</ReactFlow>
|
|
1008
977
|
)}
|
|
1009
978
|
<StudioModal isOpen={isStudioModalOpen || false} onClose={() => setIsStudioModalOpen(false)} />
|
|
979
|
+
<FocusModeModal
|
|
980
|
+
isOpen={focusModeOpen}
|
|
981
|
+
onClose={() => setFocusModeOpen(false)}
|
|
982
|
+
initialNodeId={focusedNodeId}
|
|
983
|
+
nodes={nodes}
|
|
984
|
+
edges={edges}
|
|
985
|
+
nodeTypes={nodeTypes}
|
|
986
|
+
edgeTypes={edgeTypes}
|
|
987
|
+
/>
|
|
1010
988
|
|
|
1011
989
|
{/* Share Link Modal */}
|
|
1012
990
|
{isShareModalOpen && (
|
|
@@ -137,7 +137,10 @@ export default function EntityNode({ data, sourcePosition, targetPosition }: any
|
|
|
137
137
|
</div>
|
|
138
138
|
</ContextMenu.Trigger>
|
|
139
139
|
<ContextMenu.Portal>
|
|
140
|
-
<ContextMenu.Content
|
|
140
|
+
<ContextMenu.Content
|
|
141
|
+
className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
|
|
142
|
+
onClick={(e) => e.stopPropagation()}
|
|
143
|
+
>
|
|
141
144
|
<ContextMenu.Item
|
|
142
145
|
asChild
|
|
143
146
|
className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
|
|
@@ -22,7 +22,10 @@ export default function MessageContextMenu(data: Data) {
|
|
|
22
22
|
<ContextMenu.Root>
|
|
23
23
|
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
|
|
24
24
|
<ContextMenu.Portal>
|
|
25
|
-
<ContextMenu.Content
|
|
25
|
+
<ContextMenu.Content
|
|
26
|
+
className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
|
|
27
|
+
onClick={(e) => e.stopPropagation()}
|
|
28
|
+
>
|
|
26
29
|
<ContextMenu.Item
|
|
27
30
|
asChild
|
|
28
31
|
className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
|
|
@@ -57,7 +57,10 @@ export default function ServiceNode(props: ServiceNode) {
|
|
|
57
57
|
</div>
|
|
58
58
|
</ContextMenu.Trigger>
|
|
59
59
|
<ContextMenu.Portal>
|
|
60
|
-
<ContextMenu.Content
|
|
60
|
+
<ContextMenu.Content
|
|
61
|
+
className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
|
|
62
|
+
onClick={(e) => e.stopPropagation()}
|
|
63
|
+
>
|
|
61
64
|
<ContextMenu.Item
|
|
62
65
|
asChild
|
|
63
66
|
className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
|
|
@@ -24,6 +24,11 @@ const NestedItem: React.FC<NestedItemProps> = ({
|
|
|
24
24
|
const itemId = `${parentId}-${itemIndex}`;
|
|
25
25
|
|
|
26
26
|
if (hasNestedItems && item.items) {
|
|
27
|
+
// Check if folder has an index file (slug) making it clickable
|
|
28
|
+
const folderHasLink = !!item.slug;
|
|
29
|
+
const folderPath = folderHasLink ? buildUrl(`/docs/custom/${item.slug}`) : undefined;
|
|
30
|
+
const isFolderActive = folderPath && (currentPath === folderPath || currentPath.endsWith(`/${item.slug}`));
|
|
31
|
+
|
|
27
32
|
return (
|
|
28
33
|
<div className="py-1">
|
|
29
34
|
<div className="flex items-center">
|
|
@@ -49,26 +54,53 @@ const NestedItem: React.FC<NestedItemProps> = ({
|
|
|
49
54
|
</svg>
|
|
50
55
|
</div>
|
|
51
56
|
</button>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
57
|
+
{folderHasLink ? (
|
|
58
|
+
// Folder has an index file - render as clickable link
|
|
59
|
+
<a
|
|
60
|
+
href={folderPath}
|
|
61
|
+
className={`flex items-center px-2 py-1 text-xs font-medium rounded-md min-w-0 flex-1 ${
|
|
62
|
+
isFolderActive
|
|
63
|
+
? 'bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-page-text))] font-semibold'
|
|
64
|
+
: 'text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))]'
|
|
65
|
+
}`}
|
|
66
|
+
data-active={isFolderActive}
|
|
67
|
+
>
|
|
68
|
+
<span className="truncate">{item.label}</span>
|
|
69
|
+
{item.badge && item?.badge?.text && (
|
|
70
|
+
<span
|
|
71
|
+
className={
|
|
72
|
+
item.badge.color
|
|
73
|
+
? `text-${item.badge.color}-600 dark:text-${item.badge.color}-400 ml-2 text-[10px] font-medium bg-${item.badge.color}-50 dark:bg-${item.badge.color}-500/20 px-2 py-0.5 rounded uppercase`
|
|
74
|
+
: `text-[rgb(var(--ec-accent))] ml-2 text-[10px] font-medium bg-[rgb(var(--ec-accent-subtle))] px-2 py-0.5 rounded uppercase`
|
|
75
|
+
}
|
|
76
|
+
>
|
|
77
|
+
{item.badge.text}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</a>
|
|
81
|
+
) : (
|
|
82
|
+
// Folder without index file - render as toggle button
|
|
83
|
+
<button
|
|
84
|
+
className="flex items-center px-2 py-1 text-xs font-medium text-[rgb(var(--ec-page-text-muted))] rounded-md hover:bg-[rgb(var(--ec-content-hover))] min-w-0 flex-1"
|
|
85
|
+
onClick={(e) => {
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
toggleGroupCollapse(`nested-${itemId}`);
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<span className="truncate">{item.label}</span>
|
|
91
|
+
{item.badge && item?.badge?.text && (
|
|
92
|
+
<span
|
|
93
|
+
className={
|
|
94
|
+
item.badge.color
|
|
95
|
+
? `text-${item.badge.color}-600 dark:text-${item.badge.color}-400 ml-2 text-[10px] font-medium bg-${item.badge.color}-50 dark:bg-${item.badge.color}-500/20 px-2 py-0.5 rounded uppercase`
|
|
96
|
+
: `text-[rgb(var(--ec-accent))] ml-2 text-[10px] font-medium bg-[rgb(var(--ec-accent-subtle))] px-2 py-0.5 rounded uppercase`
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
{item.badge.text}
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
72
104
|
</div>
|
|
73
105
|
|
|
74
106
|
<div
|
|
@@ -47,6 +47,8 @@ const DOCS_DIR = 'docs';
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Processes auto-generated directory and returns navigation items
|
|
50
|
+
* If an index.mdx or index.md file exists in the directory, it will be used as the
|
|
51
|
+
* folder's link target (making the folder clickable) and excluded from the items list.
|
|
50
52
|
*/
|
|
51
53
|
const processAutoGeneratedDirectory = async (
|
|
52
54
|
directory: string,
|
|
@@ -58,8 +60,33 @@ const processAutoGeneratedDirectory = async (
|
|
|
58
60
|
const items = fs.readdirSync(path.join(process.env.PROJECT_DIR || '', DOCS_DIR, directory));
|
|
59
61
|
|
|
60
62
|
const allItems: SidebarItem[] = [];
|
|
63
|
+
let folderSlug: string | undefined;
|
|
64
|
+
|
|
65
|
+
// Check for index file first (index.mdx or index.md)
|
|
66
|
+
const indexFile = items.find((item: string) => item === 'index.mdx' || item === 'index.md');
|
|
67
|
+
|
|
68
|
+
if (indexFile) {
|
|
69
|
+
const indexPath = path.join(process.env.PROJECT_DIR || '', DOCS_DIR, directory, indexFile);
|
|
70
|
+
const content = fs.readFileSync(indexPath, 'utf8');
|
|
71
|
+
const { data } = matter(content);
|
|
72
|
+
|
|
73
|
+
// Normalize path separators for cross-platform compatibility
|
|
74
|
+
// Note: Astro's glob loader strips '/index' from the ID, so docs/foo/index.mdx becomes docs/foo
|
|
75
|
+
const astroId = data.slug || path.join(DOCS_DIR, directory).replace(/\\/g, '/');
|
|
76
|
+
const entry = await getEntry('customPages', astroId.toLowerCase());
|
|
77
|
+
|
|
78
|
+
if (entry) {
|
|
79
|
+
// Use the index file's slug as the folder's link target
|
|
80
|
+
folderSlug = entry.data.slug || entry.id.replace(DOCS_DIR, '');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
61
83
|
|
|
62
84
|
for (const item of items) {
|
|
85
|
+
// Skip index files - they're used for the folder link, not as child items
|
|
86
|
+
if (item === 'index.mdx' || item === 'index.md') {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
const fullPath = path.join(process.env.PROJECT_DIR || '', DOCS_DIR, directory, item);
|
|
64
91
|
const isDirectory = fs.statSync(fullPath).isDirectory();
|
|
65
92
|
|
|
@@ -71,8 +98,8 @@ const processAutoGeneratedDirectory = async (
|
|
|
71
98
|
undefined, // No badge for subdirectories
|
|
72
99
|
collapsed // Inherit collapsed state
|
|
73
100
|
);
|
|
74
|
-
// Only add the directory if it contains valid items (mdx/md files)
|
|
75
|
-
if (subdirResult.items && subdirResult.items.length > 0) {
|
|
101
|
+
// Only add the directory if it contains valid items (mdx/md files) or has a slug (index file)
|
|
102
|
+
if ((subdirResult.items && subdirResult.items.length > 0) || subdirResult.slug) {
|
|
76
103
|
allItems.push(subdirResult);
|
|
77
104
|
}
|
|
78
105
|
} else {
|
|
@@ -81,7 +108,8 @@ const processAutoGeneratedDirectory = async (
|
|
|
81
108
|
const { data } = matter(content);
|
|
82
109
|
|
|
83
110
|
// Normalize path separators to forward slashes for cross-platform compatibility (Windows uses backslashes)
|
|
84
|
-
const astroId =
|
|
111
|
+
const astroId =
|
|
112
|
+
data.slug || path.join(DOCS_DIR, directory, item).replace('.mdx', '').replace('.md', '').replace(/\\/g, '/');
|
|
85
113
|
const entry = await getEntry('customPages', astroId.toLowerCase());
|
|
86
114
|
|
|
87
115
|
if (entry) {
|
|
@@ -97,6 +125,7 @@ const processAutoGeneratedDirectory = async (
|
|
|
97
125
|
label,
|
|
98
126
|
badge,
|
|
99
127
|
collapsed,
|
|
128
|
+
...(folderSlug && { slug: folderSlug }),
|
|
100
129
|
items: allItems,
|
|
101
130
|
};
|
|
102
131
|
};
|
|
@@ -283,7 +283,8 @@ function createMcpServer() {
|
|
|
283
283
|
if (!toolConfig || typeof toolConfig !== 'object') continue;
|
|
284
284
|
|
|
285
285
|
// Extract tool properties (Vercel AI SDK format)
|
|
286
|
-
|
|
286
|
+
// The AI SDK tool() helper uses "inputSchema" for Zod schemas
|
|
287
|
+
const { description, parameters, inputSchema, execute } = toolConfig;
|
|
287
288
|
|
|
288
289
|
if (!description || !execute) {
|
|
289
290
|
console.warn(`[MCP] Skipping invalid extended tool: ${toolName}`);
|
|
@@ -294,7 +295,7 @@ function createMcpServer() {
|
|
|
294
295
|
toolName,
|
|
295
296
|
{
|
|
296
297
|
description: description || `Custom tool: ${toolName}`,
|
|
297
|
-
inputSchema: parameters || z.object({}),
|
|
298
|
+
inputSchema: inputSchema || parameters || z.object({}),
|
|
298
299
|
},
|
|
299
300
|
async (params: any) => {
|
|
300
301
|
try {
|
|
@@ -4,7 +4,10 @@ import { showEventCatalogBranding } from '@utils/feature';
|
|
|
4
4
|
const { className } = Astro.props;
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
<footer
|
|
7
|
+
<footer
|
|
8
|
+
transition:persist="site-footer"
|
|
9
|
+
class={`relative py-4 space-y-8 border-t border-[rgb(var(--ec-page-border))] ${className}`}
|
|
10
|
+
>
|
|
8
11
|
{
|
|
9
12
|
showEventCatalogBranding() && (
|
|
10
13
|
<div class="flex justify-between items-center py-8 text-[rgb(var(--ec-page-text-muted))] text-sm font-light">
|
|
@@ -277,7 +277,9 @@ const canPageBeEmbedded = isEmbedEnabled();
|
|
|
277
277
|
{/* Load search data even when sidebar is hidden */}
|
|
278
278
|
<SearchDataLoader />
|
|
279
279
|
<main id="eventcatalog-application" class="relative">
|
|
280
|
-
<
|
|
280
|
+
<div transition:persist="site-header">
|
|
281
|
+
<Header />
|
|
282
|
+
</div>
|
|
281
283
|
<div class="flex">
|
|
282
284
|
<aside class="flex" id="eventcatalog-vertical-nav">
|
|
283
285
|
<div
|
|
@@ -44,10 +44,20 @@ export default {
|
|
|
44
44
|
'0%': { transform: 'translateX(100%)' },
|
|
45
45
|
'100%': { transform: 'translateX(-100%)' },
|
|
46
46
|
},
|
|
47
|
+
overlayShow: {
|
|
48
|
+
from: { opacity: '0' },
|
|
49
|
+
to: { opacity: '1' },
|
|
50
|
+
},
|
|
51
|
+
contentShow: {
|
|
52
|
+
from: { opacity: '0', transform: 'scale(0.96)' },
|
|
53
|
+
to: { opacity: '1', transform: 'scale(1)' },
|
|
54
|
+
},
|
|
47
55
|
},
|
|
48
56
|
animation: {
|
|
49
57
|
'progress-bar': 'progress-bar 2s linear infinite',
|
|
50
58
|
'progress-bar-reverse': 'progress-bar-reverse 2s linear infinite',
|
|
59
|
+
overlayShow: 'overlayShow 200ms ease-out',
|
|
60
|
+
contentShow: 'contentShow 200ms ease-out',
|
|
51
61
|
},
|
|
52
62
|
},
|
|
53
63
|
},
|