@eventcatalog/core 3.25.6 → 3.26.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.
Files changed (35) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-P23BMUBV.js → chunk-6BTN7CY7.js} +1 -1
  6. package/dist/{chunk-ZEOK723Y.js → chunk-EL6ZQNAX.js} +1 -1
  7. package/dist/{chunk-53HXLUNO.js → chunk-LTWPA4SA.js} +1 -1
  8. package/dist/{chunk-R7P4GTFQ.js → chunk-N3QSCVYA.js} +1 -1
  9. package/dist/{chunk-2ILJMBQM.js → chunk-Y736FREK.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +562 -19
  13. package/dist/eventcatalog.js +576 -31
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/astro.config.mjs +2 -1
  19. package/eventcatalog/integrations/eventcatalog-features.ts +13 -0
  20. package/eventcatalog/public/icons/graphql.svg +3 -1
  21. package/eventcatalog/src/components/FieldsExplorer/FieldFilters.tsx +225 -0
  22. package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +521 -0
  23. package/eventcatalog/src/components/FieldsExplorer/FieldsExplorer.tsx +501 -0
  24. package/eventcatalog/src/components/FieldsExplorer/FieldsTable.tsx +236 -0
  25. package/eventcatalog/src/enterprise/fields/field-extractor.test.ts +241 -0
  26. package/eventcatalog/src/enterprise/fields/field-extractor.ts +183 -0
  27. package/eventcatalog/src/enterprise/fields/field-indexer.ts +131 -0
  28. package/eventcatalog/src/enterprise/fields/fields-db.test.ts +186 -0
  29. package/eventcatalog/src/enterprise/fields/fields-db.ts +453 -0
  30. package/eventcatalog/src/enterprise/fields/pages/api/fields.ts +43 -0
  31. package/eventcatalog/src/enterprise/fields/pages/fields.astro +19 -0
  32. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +23 -3
  33. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +14 -16
  34. package/eventcatalog/src/utils/node-graphs/field-node-graph.ts +192 -0
  35. package/package.json +6 -4
@@ -0,0 +1,521 @@
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ ReactFlow,
4
+ ReactFlowProvider,
5
+ Background,
6
+ Controls,
7
+ useNodesState,
8
+ useEdgesState,
9
+ useReactFlow,
10
+ ConnectionLineType,
11
+ type Node,
12
+ type Edge,
13
+ } from '@xyflow/react';
14
+ import {
15
+ Event as EventNode,
16
+ Command as CommandNode,
17
+ Query as QueryNode,
18
+ Service as ServiceNode,
19
+ Field as FieldNode,
20
+ } from '@eventcatalog/visualiser';
21
+ import { X, ChevronDown, ChevronRight, Search, AlertTriangle } from 'lucide-react';
22
+ import { BoltIcon, ChatBubbleLeftIcon, MagnifyingGlassIcon, ServerIcon } from '@heroicons/react/24/solid';
23
+ import { getNodesAndEdges, type FieldOccurrence } from '@utils/node-graphs/field-node-graph';
24
+ import * as ContextMenu from '@radix-ui/react-context-menu';
25
+ import '@xyflow/react/dist/style.css';
26
+
27
+ interface FieldNodeGraphProps {
28
+ fieldPath: string;
29
+ fieldType: string;
30
+ fieldDescription?: string;
31
+ fieldRequired?: boolean;
32
+ fieldConflicts?: { type: string; count: number }[];
33
+ occurrences: FieldOccurrence[];
34
+ onClose: () => void;
35
+ }
36
+
37
+ interface ContextMenuItem {
38
+ label: string;
39
+ href: string;
40
+ external?: boolean;
41
+ download?: string;
42
+ separator?: boolean;
43
+ }
44
+
45
+ function wrapWithContextMenu(Component: React.ComponentType<any>) {
46
+ const Wrapped = memo((props: any) => {
47
+ const items: ContextMenuItem[] | undefined = props.data?.contextMenu;
48
+ if (!items?.length) return <Component {...props} />;
49
+ return (
50
+ <ContextMenu.Root>
51
+ <ContextMenu.Trigger asChild>
52
+ <div>
53
+ <Component {...props} />
54
+ </div>
55
+ </ContextMenu.Trigger>
56
+ <ContextMenu.Portal>
57
+ <ContextMenu.Content
58
+ className="min-w-[220px] bg-[rgb(var(--ec-card-bg,255_255_255))] rounded-md p-1 shadow-md border border-[rgb(var(--ec-page-border))] z-50"
59
+ onClick={(e) => e.stopPropagation()}
60
+ >
61
+ {items.map((item, index) => (
62
+ <div key={index}>
63
+ {item.separator && index > 0 && <ContextMenu.Separator className="h-[1px] bg-[rgb(var(--ec-page-border))] m-1" />}
64
+ <ContextMenu.Item
65
+ asChild
66
+ className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-[rgb(var(--ec-content-hover))] rounded-sm flex items-center text-[rgb(var(--ec-page-text))]"
67
+ >
68
+ <a
69
+ href={item.href}
70
+ {...(item.download ? { download: item.download } : {})}
71
+ {...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
72
+ >
73
+ {item.label}
74
+ </a>
75
+ </ContextMenu.Item>
76
+ </div>
77
+ ))}
78
+ </ContextMenu.Content>
79
+ </ContextMenu.Portal>
80
+ </ContextMenu.Root>
81
+ );
82
+ });
83
+ Wrapped.displayName = `WithContextMenu(${Component.displayName || Component.name || 'Component'})`;
84
+ return Wrapped;
85
+ }
86
+
87
+ const nodeTypes = {
88
+ service: wrapWithContextMenu(ServiceNode),
89
+ event: wrapWithContextMenu(EventNode),
90
+ command: wrapWithContextMenu(CommandNode),
91
+ query: wrapWithContextMenu(QueryNode),
92
+ field: FieldNode,
93
+ };
94
+
95
+ const getIconForMessageType = (type: string) => {
96
+ switch (type) {
97
+ case 'event':
98
+ return { Icon: BoltIcon, bg: 'bg-orange-500' };
99
+ case 'command':
100
+ return { Icon: ChatBubbleLeftIcon, bg: 'bg-blue-500' };
101
+ case 'query':
102
+ return { Icon: MagnifyingGlassIcon, bg: 'bg-green-500' };
103
+ default:
104
+ return { Icon: ChatBubbleLeftIcon, bg: 'bg-gray-500' };
105
+ }
106
+ };
107
+
108
+ const COLLAPSED_LIMIT = 5;
109
+
110
+ interface ResourceItem {
111
+ key: string;
112
+ label: string;
113
+ version: string;
114
+ Icon: React.ComponentType<{ className?: string }>;
115
+ bg: string;
116
+ onClick: () => void;
117
+ }
118
+
119
+ function CollapsibleResourceList({
120
+ title,
121
+ count,
122
+ items,
123
+ defaultExpanded = true,
124
+ }: {
125
+ title: string;
126
+ count: number;
127
+ items: ResourceItem[];
128
+ defaultExpanded?: boolean;
129
+ }) {
130
+ const [expanded, setExpanded] = useState(defaultExpanded);
131
+ const [showAll, setShowAll] = useState(false);
132
+ const [search, setSearch] = useState('');
133
+
134
+ const filtered = search ? items.filter((item) => item.label.toLowerCase().includes(search.toLowerCase())) : items;
135
+ const displayed = showAll ? filtered : filtered.slice(0, COLLAPSED_LIMIT);
136
+ const hasMore = filtered.length > COLLAPSED_LIMIT;
137
+
138
+ return (
139
+ <div>
140
+ <button className="flex items-center justify-between w-full mb-2 group" onClick={() => setExpanded(!expanded)}>
141
+ <h4 className="text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider flex items-center gap-1">
142
+ {expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
143
+ {title} ({count})
144
+ </h4>
145
+ </button>
146
+ {expanded && (
147
+ <div className="space-y-1.5">
148
+ {items.length > COLLAPSED_LIMIT && (
149
+ <div className="relative mb-1">
150
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[rgb(var(--ec-icon-color))]" />
151
+ <input
152
+ type="text"
153
+ placeholder={`Filter ${title.toLowerCase()}...`}
154
+ value={search}
155
+ onChange={(e) => {
156
+ setSearch(e.target.value);
157
+ setShowAll(true);
158
+ }}
159
+ className="w-full pl-7 pr-2 py-1 text-[11px] rounded border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-input-bg,var(--ec-page-bg)))] text-[rgb(var(--ec-page-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))]"
160
+ onClick={(e) => e.stopPropagation()}
161
+ />
162
+ </div>
163
+ )}
164
+ {displayed.map((item) => (
165
+ <button
166
+ key={item.key}
167
+ className="flex items-center gap-2 w-full rounded-lg border border-[rgb(var(--ec-page-border)/0.5)] bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] hover:border-[rgb(var(--ec-accent))] transition-colors px-2.5 py-2 text-left"
168
+ onClick={(e) => {
169
+ e.stopPropagation();
170
+ item.onClick();
171
+ }}
172
+ >
173
+ <span className={`flex items-center justify-center w-5 h-5 ${item.bg} rounded flex-shrink-0`}>
174
+ <item.Icon className="h-2.5 w-2.5 text-white" />
175
+ </span>
176
+ <div className="min-w-0">
177
+ <div className="text-xs font-medium text-[rgb(var(--ec-page-text))] truncate">{item.label}</div>
178
+ <div className="text-[10px] text-[rgb(var(--ec-page-text-muted))]">v{item.version}</div>
179
+ </div>
180
+ </button>
181
+ ))}
182
+ {hasMore && !search && (
183
+ <button
184
+ className="text-[11px] text-[rgb(var(--ec-accent))] hover:underline w-full text-left pl-1"
185
+ onClick={(e) => {
186
+ e.stopPropagation();
187
+ setShowAll(!showAll);
188
+ }}
189
+ >
190
+ {showAll ? 'Show less' : `Show all ${filtered.length}`}
191
+ </button>
192
+ )}
193
+ {search && filtered.length === 0 && (
194
+ <div className="text-[11px] text-[rgb(var(--ec-page-text-muted))] pl-1">No matches</div>
195
+ )}
196
+ </div>
197
+ )}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ function FieldNodeGraphInner({
203
+ fieldPath,
204
+ fieldType,
205
+ fieldDescription,
206
+ fieldRequired,
207
+ fieldConflicts,
208
+ occurrences,
209
+ onClose,
210
+ }: FieldNodeGraphProps) {
211
+ const { nodes: initialNodes, edges: initialEdges } = useMemo(
212
+ () => getNodesAndEdges({ fieldPath, fieldType, occurrences }),
213
+ [fieldPath, fieldType, occurrences]
214
+ );
215
+
216
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
217
+ const [edges, , onEdgesChange] = useEdgesState(initialEdges);
218
+ const edgesRef = useRef(initialEdges);
219
+ edgesRef.current = edges;
220
+
221
+ const { fitView } = useReactFlow();
222
+ const wrapperRef = useRef<HTMLDivElement>(null);
223
+
224
+ // Load visualiser styles (animations, hover effects, theme variables)
225
+ useEffect(() => {
226
+ import('@eventcatalog/visualiser/styles-core.css');
227
+ }, []);
228
+
229
+ useEffect(() => {
230
+ const timer = setTimeout(() => fitView({ padding: 0.2 }), 50);
231
+ return () => clearTimeout(timer);
232
+ }, [fitView]);
233
+
234
+ // Unique messages, producers, consumers for the details panel
235
+ const uniqueMessages = useMemo(() => {
236
+ const seen = new Set<string>();
237
+ return occurrences.filter((occ) => {
238
+ const key = `${occ.messageId}-${occ.messageVersion}`;
239
+ if (seen.has(key)) return false;
240
+ seen.add(key);
241
+ return true;
242
+ });
243
+ }, [occurrences]);
244
+
245
+ const uniqueProducers = useMemo(() => {
246
+ const seen = new Set<string>();
247
+ const result: { id: string; version: string; name?: string }[] = [];
248
+ for (const occ of occurrences) {
249
+ for (const p of occ.producers) {
250
+ const key = `${p.id}-${p.version}`;
251
+ if (!seen.has(key)) {
252
+ seen.add(key);
253
+ result.push(p);
254
+ }
255
+ }
256
+ }
257
+ return result;
258
+ }, [occurrences]);
259
+
260
+ const uniqueConsumers = useMemo(() => {
261
+ const seen = new Set<string>();
262
+ const result: { id: string; version: string; name?: string }[] = [];
263
+ for (const occ of occurrences) {
264
+ for (const c of occ.consumers) {
265
+ const key = `${c.id}-${c.version}`;
266
+ if (!seen.has(key)) {
267
+ seen.add(key);
268
+ result.push(c);
269
+ }
270
+ }
271
+ }
272
+ return result;
273
+ }, [occurrences]);
274
+
275
+ // ── Hover highlighting (matches main NodeGraph behaviour) ──
276
+
277
+ const hoveredEdgeNodesRef = useRef<Element[]>([]);
278
+ const hoveredNodeEdgesRef = useRef<Element[]>([]);
279
+ const hoveredNodePeersRef = useRef<Element[]>([]);
280
+
281
+ const handleEdgeMouseEnter = useCallback((_: React.MouseEvent, edge: Edge) => {
282
+ const wrapper = wrapperRef.current;
283
+ if (!wrapper) return;
284
+ const nodeEls = wrapper.querySelectorAll(`[data-id="${edge.source}"], [data-id="${edge.target}"]`);
285
+ nodeEls.forEach((el) => el.classList.add('ec-edge-hover-node'));
286
+ hoveredEdgeNodesRef.current = Array.from(nodeEls);
287
+ }, []);
288
+
289
+ const handleEdgeMouseLeave = useCallback(() => {
290
+ hoveredEdgeNodesRef.current.forEach((el) => el.classList.remove('ec-edge-hover-node'));
291
+ hoveredEdgeNodesRef.current = [];
292
+ }, []);
293
+
294
+ const handleNodeMouseEnter = useCallback((_: React.MouseEvent, node: Node) => {
295
+ const wrapper = wrapperRef.current;
296
+ if (!wrapper) return;
297
+
298
+ const peerIds = new Set<string>();
299
+ const edgeEls: Element[] = [];
300
+
301
+ for (const edge of edgesRef.current) {
302
+ if (edge.source !== node.id && edge.target !== node.id) continue;
303
+ const el = wrapper.querySelector(`.react-flow__edge[data-id="${edge.id}"]`);
304
+ if (el) {
305
+ el.classList.add('ec-node-hover-edge');
306
+ edgeEls.push(el);
307
+ }
308
+ if (edge.source !== node.id) peerIds.add(edge.source);
309
+ if (edge.target !== node.id) peerIds.add(edge.target);
310
+ }
311
+ hoveredNodeEdgesRef.current = edgeEls;
312
+
313
+ peerIds.add(node.id);
314
+ const selector = Array.from(peerIds)
315
+ .map((id) => `[data-id="${id}"]`)
316
+ .join(', ');
317
+ if (selector) {
318
+ const peerEls = wrapper.querySelectorAll(selector);
319
+ peerEls.forEach((el) => el.classList.add('ec-edge-hover-node'));
320
+ hoveredNodePeersRef.current = Array.from(peerEls);
321
+ }
322
+ }, []);
323
+
324
+ const handleNodeMouseLeave = useCallback(() => {
325
+ hoveredNodeEdgesRef.current.forEach((el) => el.classList.remove('ec-node-hover-edge'));
326
+ hoveredNodeEdgesRef.current = [];
327
+ hoveredNodePeersRef.current.forEach((el) => el.classList.remove('ec-edge-hover-node'));
328
+ hoveredNodePeersRef.current = [];
329
+ }, []);
330
+
331
+ return (
332
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
333
+ <div
334
+ className="w-[90vw] h-[80vh] max-w-[1400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
335
+ style={{
336
+ backgroundColor: 'rgb(var(--ec-page-bg))',
337
+ borderColor: 'rgb(var(--ec-page-border))',
338
+ }}
339
+ onClick={(e) => e.stopPropagation()}
340
+ >
341
+ {/* Header */}
342
+ <div
343
+ className="flex items-center justify-between px-5 py-3 border-b flex-shrink-0"
344
+ style={{ borderColor: 'rgb(var(--ec-page-border))' }}
345
+ >
346
+ <div className="flex items-center gap-2">
347
+ <h3 className="text-sm font-semibold" style={{ color: 'rgb(var(--ec-page-text))' }}>
348
+ Field Traceability
349
+ </h3>
350
+ <code
351
+ className="px-2 py-0.5 rounded text-xs font-mono"
352
+ style={{
353
+ backgroundColor: 'rgb(var(--ec-accent) / 0.1)',
354
+ color: 'rgb(var(--ec-accent))',
355
+ }}
356
+ >
357
+ {fieldPath}
358
+ </code>
359
+ </div>
360
+ <button
361
+ onClick={onClose}
362
+ className="p-1.5 rounded-lg transition-colors hover:bg-[rgb(var(--ec-content-hover))] flex-shrink-0"
363
+ style={{ color: 'rgb(var(--ec-icon-color))' }}
364
+ title="Close"
365
+ >
366
+ <X className="w-4 h-4" />
367
+ </button>
368
+ </div>
369
+
370
+ {/* Body: Graph + Details panel */}
371
+ <div className="flex-1 min-h-0 flex">
372
+ {/* Node Graph */}
373
+ <div className="flex-1 min-w-0 eventcatalog-visualizer" ref={wrapperRef}>
374
+ <ReactFlow
375
+ nodes={nodes}
376
+ edges={edges}
377
+ onNodesChange={onNodesChange}
378
+ onEdgesChange={onEdgesChange}
379
+ nodeTypes={nodeTypes}
380
+ onEdgeMouseEnter={handleEdgeMouseEnter}
381
+ onEdgeMouseLeave={handleEdgeMouseLeave}
382
+ onNodeMouseEnter={handleNodeMouseEnter}
383
+ onNodeMouseLeave={handleNodeMouseLeave}
384
+ defaultEdgeOptions={{ type: 'smoothstep' }}
385
+ connectionLineType={ConnectionLineType.SmoothStep}
386
+ fitView
387
+ proOptions={{ hideAttribution: true }}
388
+ nodesDraggable={true}
389
+ nodesConnectable={false}
390
+ elementsSelectable={false}
391
+ minZoom={0.07}
392
+ maxZoom={1.5}
393
+ >
394
+ <Background color="var(--ec-bg-dots)" gap={16} />
395
+ <Controls position="bottom-left" />
396
+ </ReactFlow>
397
+ </div>
398
+
399
+ {/* Right details panel */}
400
+ <div className="w-[280px] flex-shrink-0 border-l overflow-y-auto" style={{ borderColor: 'rgb(var(--ec-page-border))' }}>
401
+ <div className="p-4 space-y-4">
402
+ {/* Field info */}
403
+ <div>
404
+ <h4 className="text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))] uppercase tracking-wider mb-2">
405
+ Field
406
+ </h4>
407
+ <div className="space-y-1.5">
408
+ <div className="text-sm font-mono font-semibold text-[rgb(var(--ec-page-text))]">{fieldPath}</div>
409
+ <div className="text-xs text-[rgb(var(--ec-page-text-muted))]">{fieldType}</div>
410
+ {fieldRequired && (
411
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
412
+ Required
413
+ </span>
414
+ )}
415
+ {fieldDescription && (
416
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))] leading-relaxed mt-1">{fieldDescription}</p>
417
+ )}
418
+ </div>
419
+ </div>
420
+
421
+ {/* Conflicts */}
422
+ {fieldConflicts && fieldConflicts.length > 1 && (
423
+ <div>
424
+ <h4 className="text-[11px] font-medium text-amber-500 uppercase tracking-wider mb-2 flex items-center gap-1">
425
+ <AlertTriangle className="w-3 h-3" />
426
+ Type Conflict
427
+ </h4>
428
+ <div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-2.5">
429
+ <p className="text-xs text-amber-600 mb-2">
430
+ This field has inconsistent types across messages (
431
+ {fieldConflicts.map((c) => `${c.type} in ${c.count}`).join(', ')})
432
+ </p>
433
+ <div className="space-y-1">
434
+ {fieldConflicts.map((c) => (
435
+ <div key={c.type} className="flex items-center justify-between text-xs">
436
+ <code className="font-mono text-[rgb(var(--ec-page-text))]">{c.type}</code>
437
+ <span className="text-[rgb(var(--ec-page-text-muted))]">
438
+ {c.count} {c.count === 1 ? 'schema' : 'schemas'}
439
+ </span>
440
+ </div>
441
+ ))}
442
+ </div>
443
+ </div>
444
+ </div>
445
+ )}
446
+
447
+ {/* Messages */}
448
+ <CollapsibleResourceList
449
+ title="Messages"
450
+ count={uniqueMessages.length}
451
+ defaultExpanded={true}
452
+ items={uniqueMessages.map((occ) => {
453
+ const { Icon, bg } = getIconForMessageType(occ.messageType);
454
+ return {
455
+ key: `${occ.messageId}-${occ.messageVersion}`,
456
+ label: occ.messageName || occ.messageId,
457
+ version: occ.messageVersion,
458
+ Icon,
459
+ bg,
460
+ onClick: () => {
461
+ const nodeId = `msg-${occ.messageId}-${occ.messageVersion}`;
462
+ fitView({ nodes: [{ id: nodeId }], duration: 300, padding: 0.5 });
463
+ },
464
+ };
465
+ })}
466
+ />
467
+
468
+ {/* Producers */}
469
+ {uniqueProducers.length > 0 && (
470
+ <CollapsibleResourceList
471
+ title="Producers"
472
+ count={uniqueProducers.length}
473
+ defaultExpanded={false}
474
+ items={uniqueProducers.map((p) => ({
475
+ key: `${p.id}-${p.version}`,
476
+ label: p.name || p.id,
477
+ version: p.version,
478
+ Icon: ServerIcon,
479
+ bg: 'bg-pink-500',
480
+ onClick: () => {
481
+ const nodeId = `svc-producer-${p.id}-${p.version}`;
482
+ fitView({ nodes: [{ id: nodeId }], duration: 300, padding: 0.5 });
483
+ },
484
+ }))}
485
+ />
486
+ )}
487
+
488
+ {/* Consumers */}
489
+ {uniqueConsumers.length > 0 && (
490
+ <CollapsibleResourceList
491
+ title="Consumers"
492
+ count={uniqueConsumers.length}
493
+ defaultExpanded={false}
494
+ items={uniqueConsumers.map((c) => ({
495
+ key: `${c.id}-${c.version}`,
496
+ label: c.name || c.id,
497
+ version: c.version,
498
+ Icon: ServerIcon,
499
+ bg: 'bg-pink-500',
500
+ onClick: () => {
501
+ const nodeId = `svc-consumer-${c.id}-${c.version}`;
502
+ fitView({ nodes: [{ id: nodeId }], duration: 300, padding: 0.5 });
503
+ },
504
+ }))}
505
+ />
506
+ )}
507
+ </div>
508
+ </div>
509
+ </div>
510
+ </div>
511
+ </div>
512
+ );
513
+ }
514
+
515
+ export default function FieldNodeGraph(props: FieldNodeGraphProps) {
516
+ return (
517
+ <ReactFlowProvider>
518
+ <FieldNodeGraphInner {...props} />
519
+ </ReactFlowProvider>
520
+ );
521
+ }