@eventcatalog/core 2.44.0 → 2.44.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-NUHIER2H.js → chunk-AUGREOCT.js} +1 -1
- package/dist/{chunk-MF3FQSRF.js → chunk-ECUXDBJ7.js} +1 -1
- package/dist/{chunk-C4NENBPQ.js → chunk-HGLZ22GT.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 +3 -3
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +1 -0
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.tsx +116 -43
- package/eventcatalog/src/components/MDX/NodeGraph/VisualiserSearch.tsx +207 -0
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
log_build_default
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
5
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-ECUXDBJ7.js";
|
|
4
|
+
import "../chunk-AUGREOCT.js";
|
|
5
|
+
import "../chunk-HGLZ22GT.js";
|
|
6
6
|
import "../chunk-E7TXTI7G.js";
|
|
7
7
|
export {
|
|
8
8
|
log_build_default as default
|
package/dist/constants.cjs
CHANGED
package/dist/constants.js
CHANGED
package/dist/eventcatalog.cjs
CHANGED
package/dist/eventcatalog.js
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
} from "./chunk-DCLTVJDP.js";
|
|
7
7
|
import {
|
|
8
8
|
log_build_default
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import "./chunk-
|
|
9
|
+
} from "./chunk-ECUXDBJ7.js";
|
|
10
|
+
import "./chunk-AUGREOCT.js";
|
|
11
11
|
import {
|
|
12
12
|
catalogToAstro,
|
|
13
13
|
checkAndConvertMdToMdx
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import "./chunk-EXAALOQA.js";
|
|
16
16
|
import {
|
|
17
17
|
VERSION
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-HGLZ22GT.js";
|
|
19
19
|
import {
|
|
20
20
|
isAuthEnabled,
|
|
21
21
|
isBackstagePluginEnabled,
|
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
type Edge,
|
|
13
13
|
type Node,
|
|
14
14
|
useReactFlow,
|
|
15
|
+
getNodesBounds,
|
|
16
|
+
getViewportForBounds,
|
|
15
17
|
} from '@xyflow/react';
|
|
16
18
|
import '@xyflow/react/dist/style.css';
|
|
17
19
|
import { HistoryIcon } from 'lucide-react';
|
|
20
|
+
import { toPng } from 'html-to-image';
|
|
21
|
+
import { DocumentArrowDownIcon } from '@heroicons/react/24/outline';
|
|
18
22
|
// Nodes and edges
|
|
19
23
|
import ServiceNode from './Nodes/Service';
|
|
20
24
|
import FlowNode from './Nodes/Flow';
|
|
@@ -31,11 +35,11 @@ import CustomNode from './Nodes/Custom';
|
|
|
31
35
|
import type { CollectionEntry } from 'astro:content';
|
|
32
36
|
import { navigate } from 'astro:transitions/client';
|
|
33
37
|
import type { CollectionTypes } from '@types';
|
|
34
|
-
import DownloadButton from './DownloadButton';
|
|
35
38
|
import { buildUrl } from '@utils/url-builder';
|
|
36
39
|
import ChannelNode from './Nodes/Channel';
|
|
37
40
|
import { CogIcon } from '@heroicons/react/20/solid';
|
|
38
41
|
import { useEventCatalogVisualiser } from 'src/hooks/eventcatalog-visualizer';
|
|
42
|
+
import VisualiserSearch, { type VisualiserSearchRef } from './VisualiserSearch';
|
|
39
43
|
interface Props {
|
|
40
44
|
nodes: any;
|
|
41
45
|
edges: any;
|
|
@@ -47,6 +51,7 @@ interface Props {
|
|
|
47
51
|
includeKey?: boolean;
|
|
48
52
|
linksToVisualiser?: boolean;
|
|
49
53
|
links?: { label: string; url: string }[];
|
|
54
|
+
mode?: 'full' | 'simple';
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
const getVisualiserUrlForCollection = (collectionItem: CollectionEntry<CollectionTypes>) => {
|
|
@@ -62,6 +67,7 @@ const NodeGraphBuilder = ({
|
|
|
62
67
|
includeKey = true,
|
|
63
68
|
linksToVisualiser = false,
|
|
64
69
|
links = [],
|
|
70
|
+
mode = 'full',
|
|
65
71
|
}: Props) => {
|
|
66
72
|
const nodeTypes = useMemo(
|
|
67
73
|
() => ({
|
|
@@ -92,7 +98,8 @@ const NodeGraphBuilder = ({
|
|
|
92
98
|
const [isAnimated, setIsAnimated] = useState(false);
|
|
93
99
|
const [animateMessages, setAnimateMessages] = useState(false);
|
|
94
100
|
const { hideChannels, toggleChannelsVisibility } = useEventCatalogVisualiser({ nodes, edges, setNodes, setEdges });
|
|
95
|
-
const { fitView } = useReactFlow();
|
|
101
|
+
const { fitView, getNodes } = useReactFlow();
|
|
102
|
+
const searchRef = useRef<VisualiserSearchRef>(null);
|
|
96
103
|
|
|
97
104
|
const resetNodesAndEdges = useCallback(() => {
|
|
98
105
|
setNodes((nds) =>
|
|
@@ -200,10 +207,59 @@ const NodeGraphBuilder = ({
|
|
|
200
207
|
|
|
201
208
|
const handlePaneClick = useCallback(() => {
|
|
202
209
|
setIsSettingsOpen(false);
|
|
210
|
+
searchRef.current?.hideSuggestions();
|
|
203
211
|
resetNodesAndEdges();
|
|
204
212
|
fitView({ duration: 800 });
|
|
205
213
|
}, [resetNodesAndEdges, fitView]);
|
|
206
214
|
|
|
215
|
+
const handleNodeSelect = useCallback(
|
|
216
|
+
(node: Node) => {
|
|
217
|
+
handleNodeClick(null, node);
|
|
218
|
+
},
|
|
219
|
+
[handleNodeClick]
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const handleSearchClear = useCallback(() => {
|
|
223
|
+
resetNodesAndEdges();
|
|
224
|
+
fitView({ duration: 800 });
|
|
225
|
+
}, [resetNodesAndEdges, fitView]);
|
|
226
|
+
|
|
227
|
+
const downloadImage = useCallback((dataUrl: string, filename?: string) => {
|
|
228
|
+
const a = document.createElement('a');
|
|
229
|
+
a.setAttribute('download', `${filename || 'eventcatalog'}.png`);
|
|
230
|
+
a.setAttribute('href', dataUrl);
|
|
231
|
+
a.click();
|
|
232
|
+
}, []);
|
|
233
|
+
|
|
234
|
+
const handleExportVisual = useCallback(() => {
|
|
235
|
+
const imageWidth = 1024;
|
|
236
|
+
const imageHeight = 768;
|
|
237
|
+
const nodesBounds = getNodesBounds(getNodes());
|
|
238
|
+
const width = imageWidth > nodesBounds.width ? imageWidth : nodesBounds.width;
|
|
239
|
+
const height = imageHeight > nodesBounds.height ? imageHeight : nodesBounds.height;
|
|
240
|
+
const viewport = getViewportForBounds(nodesBounds, width, height, 0.5, 2, 0);
|
|
241
|
+
|
|
242
|
+
// Hide settings panel and controls during export
|
|
243
|
+
setIsSettingsOpen(false);
|
|
244
|
+
const controls = document.querySelector('.react-flow__controls') as HTMLElement;
|
|
245
|
+
if (controls) controls.style.display = 'none';
|
|
246
|
+
|
|
247
|
+
toPng(document.querySelector('.react-flow__viewport') as HTMLElement, {
|
|
248
|
+
backgroundColor: '#f1f1f1',
|
|
249
|
+
width,
|
|
250
|
+
height,
|
|
251
|
+
style: {
|
|
252
|
+
width: width.toString(),
|
|
253
|
+
height: height.toString(),
|
|
254
|
+
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
|
255
|
+
},
|
|
256
|
+
}).then((dataUrl: string) => {
|
|
257
|
+
downloadImage(dataUrl, title);
|
|
258
|
+
// Restore controls
|
|
259
|
+
if (controls) controls.style.display = 'block';
|
|
260
|
+
});
|
|
261
|
+
}, [getNodes, downloadImage, title]);
|
|
262
|
+
|
|
207
263
|
const handleLegendClick = useCallback(
|
|
208
264
|
(collectionType: string, groupId?: string) => {
|
|
209
265
|
const updatedNodes = nodes.map((node: Node<any>) => {
|
|
@@ -304,50 +360,55 @@ const NodeGraphBuilder = ({
|
|
|
304
360
|
className="relative"
|
|
305
361
|
>
|
|
306
362
|
<Panel position="top-center" className="w-full pr-6 ">
|
|
307
|
-
<div className="flex space-x-2 justify-between
|
|
308
|
-
<div>
|
|
309
|
-
<
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
<div className="flex justify-end space-x-2">
|
|
323
|
-
<DownloadButton filename={title} addPadding={false} />
|
|
324
|
-
{/* // Dropdown for links */}
|
|
325
|
-
{links.length > 0 && (
|
|
326
|
-
<div className="relative flex items-center -mt-1">
|
|
327
|
-
<span className="absolute left-2 pointer-events-none flex items-center h-full">
|
|
328
|
-
<HistoryIcon className="h-4 w-4 text-gray-600" />
|
|
329
|
-
</span>
|
|
330
|
-
<select
|
|
331
|
-
value={links.find((link) => window.location.href.includes(link.url))?.url || links[0].url}
|
|
332
|
-
onChange={(e) => navigate(e.target.value)}
|
|
333
|
-
className="appearance-none pl-7 pr-6 py-0 text-[14px] bg-white rounded-md border border-gray-200 hover:bg-gray-100/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
|
334
|
-
style={{ minWidth: 120, height: '26px' }}
|
|
335
|
-
>
|
|
336
|
-
{links.map((link) => (
|
|
337
|
-
<option key={link.url} value={link.url}>
|
|
338
|
-
{link.label}
|
|
339
|
-
</option>
|
|
340
|
-
))}
|
|
341
|
-
</select>
|
|
342
|
-
<span className="absolute right-2 pointer-events-none">
|
|
343
|
-
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
344
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
345
|
-
</svg>
|
|
346
|
-
</span>
|
|
347
|
-
</div>
|
|
363
|
+
<div className="flex space-x-2 justify-between items-center">
|
|
364
|
+
<div className="flex space-x-2">
|
|
365
|
+
<div>
|
|
366
|
+
<button
|
|
367
|
+
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
|
368
|
+
className="py-2.5 px-3 bg-white rounded-md shadow-md hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
|
369
|
+
aria-label="Open settings"
|
|
370
|
+
>
|
|
371
|
+
<CogIcon className="h-5 w-5 text-gray-600" />
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
{title && (
|
|
375
|
+
<span className="block shadow-sm bg-white text-xl z-10 text-black px-4 py-1.5 border-gray-200 rounded-md border opacity-80">
|
|
376
|
+
{title}
|
|
377
|
+
</span>
|
|
348
378
|
)}
|
|
349
379
|
</div>
|
|
380
|
+
{mode === 'full' && (
|
|
381
|
+
<div className="flex justify-end space-x-2 w-96">
|
|
382
|
+
<VisualiserSearch ref={searchRef} nodes={nodes} onNodeSelect={handleNodeSelect} onClear={handleSearchClear} />
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
350
385
|
</div>
|
|
386
|
+
{links.length > 0 && (
|
|
387
|
+
<div className="flex justify-end mt-3">
|
|
388
|
+
<div className="relative flex items-center -mt-1">
|
|
389
|
+
<span className="absolute left-2 pointer-events-none flex items-center h-full">
|
|
390
|
+
<HistoryIcon className="h-4 w-4 text-gray-600" />
|
|
391
|
+
</span>
|
|
392
|
+
<select
|
|
393
|
+
value={links.find((link) => window.location.href.includes(link.url))?.url || links[0].url}
|
|
394
|
+
onChange={(e) => navigate(e.target.value)}
|
|
395
|
+
className="appearance-none pl-7 pr-6 py-0 text-[14px] bg-white rounded-md border border-gray-200 hover:bg-gray-100/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
|
396
|
+
style={{ minWidth: 120, height: '26px' }}
|
|
397
|
+
>
|
|
398
|
+
{links.map((link) => (
|
|
399
|
+
<option key={link.url} value={link.url}>
|
|
400
|
+
{link.label}
|
|
401
|
+
</option>
|
|
402
|
+
))}
|
|
403
|
+
</select>
|
|
404
|
+
<span className="absolute right-2 pointer-events-none">
|
|
405
|
+
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
406
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
407
|
+
</svg>
|
|
408
|
+
</span>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
351
412
|
</Panel>
|
|
352
413
|
|
|
353
414
|
{isSettingsOpen && (
|
|
@@ -396,6 +457,15 @@ const NodeGraphBuilder = ({
|
|
|
396
457
|
</div>
|
|
397
458
|
<p className="text-[10px] text-gray-500">Show or hide channels in the visualizer.</p>
|
|
398
459
|
</div>
|
|
460
|
+
<div className="pt-4 border-t border-gray-200">
|
|
461
|
+
<button
|
|
462
|
+
onClick={handleExportVisual}
|
|
463
|
+
className="w-full flex items-center justify-center space-x-2 px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
|
464
|
+
>
|
|
465
|
+
<DocumentArrowDownIcon className="w-4 h-4" />
|
|
466
|
+
<span>Export Visual</span>
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
399
469
|
</div>
|
|
400
470
|
</div>
|
|
401
471
|
)}
|
|
@@ -437,6 +507,7 @@ interface NodeGraphProps {
|
|
|
437
507
|
footerLabel?: string;
|
|
438
508
|
linksToVisualiser?: boolean;
|
|
439
509
|
links?: { label: string; url: string }[];
|
|
510
|
+
mode?: 'full' | 'simple';
|
|
440
511
|
}
|
|
441
512
|
|
|
442
513
|
const NodeGraph = ({
|
|
@@ -451,6 +522,7 @@ const NodeGraph = ({
|
|
|
451
522
|
footerLabel,
|
|
452
523
|
linksToVisualiser = false,
|
|
453
524
|
links = [],
|
|
525
|
+
mode = 'full',
|
|
454
526
|
}: NodeGraphProps) => {
|
|
455
527
|
const [elem, setElem] = useState(null);
|
|
456
528
|
const [showFooter, setShowFooter] = useState(true);
|
|
@@ -482,6 +554,7 @@ const NodeGraph = ({
|
|
|
482
554
|
includeKey={includeKey}
|
|
483
555
|
linksToVisualiser={linksToVisualiser}
|
|
484
556
|
links={links}
|
|
557
|
+
mode={mode}
|
|
485
558
|
/>
|
|
486
559
|
|
|
487
560
|
{showFooter && (
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
|
2
|
+
import type { Node } from '@xyflow/react';
|
|
3
|
+
|
|
4
|
+
interface VisualiserSearchProps {
|
|
5
|
+
nodes: Node[];
|
|
6
|
+
onNodeSelect: (node: Node) => void;
|
|
7
|
+
onClear: () => void;
|
|
8
|
+
onPaneClick?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VisualiserSearchRef {
|
|
12
|
+
hideSuggestions: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const VisualiserSearch = forwardRef<VisualiserSearchRef, VisualiserSearchProps>(
|
|
16
|
+
({ nodes, onNodeSelect, onClear, onPaneClick }, ref) => {
|
|
17
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
18
|
+
const [filteredSuggestions, setFilteredSuggestions] = useState<Node[]>([]);
|
|
19
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
20
|
+
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
|
21
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
const hideSuggestions = useCallback(() => {
|
|
25
|
+
setShowSuggestions(false);
|
|
26
|
+
setSelectedSuggestionIndex(-1);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
useImperativeHandle(
|
|
30
|
+
ref,
|
|
31
|
+
() => ({
|
|
32
|
+
hideSuggestions,
|
|
33
|
+
}),
|
|
34
|
+
[hideSuggestions]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const getNodeDisplayName = useCallback((node: Node) => {
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
const name = node.data?.message?.data?.name || node.data?.service?.data?.name || node.data?.name || node.id;
|
|
40
|
+
// @ts-ignore
|
|
41
|
+
const version = node.data?.message?.data?.version || node.data?.service?.data?.version || node.data?.version;
|
|
42
|
+
return version ? `${name} (v${version})` : name;
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const getNodeTypeColorClass = useCallback((nodeType: string) => {
|
|
46
|
+
const colorClasses: { [key: string]: string } = {
|
|
47
|
+
events: 'bg-orange-600 text-white',
|
|
48
|
+
services: 'bg-pink-600 text-white',
|
|
49
|
+
flows: 'bg-teal-600 text-white',
|
|
50
|
+
commands: 'bg-blue-600 text-white',
|
|
51
|
+
queries: 'bg-green-600 text-white',
|
|
52
|
+
channels: 'bg-gray-600 text-white',
|
|
53
|
+
externalSystem: 'bg-pink-600 text-white',
|
|
54
|
+
actor: 'bg-yellow-500 text-white',
|
|
55
|
+
step: 'bg-gray-700 text-white',
|
|
56
|
+
user: 'bg-yellow-500 text-white',
|
|
57
|
+
custom: 'bg-gray-500 text-white',
|
|
58
|
+
};
|
|
59
|
+
return colorClasses[nodeType] || 'bg-gray-100 text-gray-700';
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const handleSearchChange = useCallback(
|
|
63
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
64
|
+
const query = event.target.value;
|
|
65
|
+
setSearchQuery(query);
|
|
66
|
+
|
|
67
|
+
if (query.length > 0) {
|
|
68
|
+
const filtered = nodes.filter((node) => {
|
|
69
|
+
const nodeName = getNodeDisplayName(node);
|
|
70
|
+
return nodeName.toLowerCase().includes(query.toLowerCase());
|
|
71
|
+
});
|
|
72
|
+
setFilteredSuggestions(filtered);
|
|
73
|
+
setShowSuggestions(true);
|
|
74
|
+
setSelectedSuggestionIndex(-1);
|
|
75
|
+
} else {
|
|
76
|
+
setFilteredSuggestions(nodes);
|
|
77
|
+
setShowSuggestions(true);
|
|
78
|
+
setSelectedSuggestionIndex(-1);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[nodes, getNodeDisplayName]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const handleSearchFocus = useCallback(() => {
|
|
85
|
+
if (searchQuery.length === 0) {
|
|
86
|
+
setFilteredSuggestions(nodes);
|
|
87
|
+
}
|
|
88
|
+
setShowSuggestions(true);
|
|
89
|
+
setSelectedSuggestionIndex(-1);
|
|
90
|
+
}, [nodes, searchQuery]);
|
|
91
|
+
|
|
92
|
+
const handleSuggestionClick = useCallback(
|
|
93
|
+
(node: Node) => {
|
|
94
|
+
setSearchQuery(getNodeDisplayName(node));
|
|
95
|
+
setShowSuggestions(false);
|
|
96
|
+
onNodeSelect(node);
|
|
97
|
+
},
|
|
98
|
+
[onNodeSelect, getNodeDisplayName]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const handleSearchKeyDown = useCallback(
|
|
102
|
+
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
103
|
+
if (!showSuggestions || filteredSuggestions.length === 0) return;
|
|
104
|
+
|
|
105
|
+
switch (event.key) {
|
|
106
|
+
case 'ArrowDown':
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
setSelectedSuggestionIndex((prev) => (prev < filteredSuggestions.length - 1 ? prev + 1 : 0));
|
|
109
|
+
break;
|
|
110
|
+
case 'ArrowUp':
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
setSelectedSuggestionIndex((prev) => (prev > 0 ? prev - 1 : filteredSuggestions.length - 1));
|
|
113
|
+
break;
|
|
114
|
+
case 'Enter':
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
if (selectedSuggestionIndex >= 0) {
|
|
117
|
+
handleSuggestionClick(filteredSuggestions[selectedSuggestionIndex]);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'Escape':
|
|
121
|
+
setShowSuggestions(false);
|
|
122
|
+
setSelectedSuggestionIndex(-1);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[showSuggestions, filteredSuggestions, selectedSuggestionIndex, handleSuggestionClick]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const clearSearch = useCallback(() => {
|
|
130
|
+
setSearchQuery('');
|
|
131
|
+
setShowSuggestions(false);
|
|
132
|
+
setFilteredSuggestions([]);
|
|
133
|
+
setSelectedSuggestionIndex(-1);
|
|
134
|
+
onClear();
|
|
135
|
+
if (searchInputRef.current) {
|
|
136
|
+
searchInputRef.current.focus();
|
|
137
|
+
}
|
|
138
|
+
}, [onClear]);
|
|
139
|
+
|
|
140
|
+
// Close suggestions when clicking outside
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
143
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
144
|
+
setShowSuggestions(false);
|
|
145
|
+
setSelectedSuggestionIndex(-1);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
150
|
+
return () => {
|
|
151
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
152
|
+
};
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div ref={containerRef} className="w-full max-w-md mx-auto relative">
|
|
157
|
+
<div className="relative">
|
|
158
|
+
<input
|
|
159
|
+
ref={searchInputRef}
|
|
160
|
+
type="text"
|
|
161
|
+
placeholder="Search nodes..."
|
|
162
|
+
value={searchQuery}
|
|
163
|
+
onChange={handleSearchChange}
|
|
164
|
+
onKeyDown={handleSearchKeyDown}
|
|
165
|
+
onFocus={handleSearchFocus}
|
|
166
|
+
className="w-full px-4 py-2 pr-10 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
167
|
+
/>
|
|
168
|
+
{searchQuery && (
|
|
169
|
+
<button
|
|
170
|
+
onClick={clearSearch}
|
|
171
|
+
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
172
|
+
aria-label="Clear search"
|
|
173
|
+
>
|
|
174
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
175
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
176
|
+
</svg>
|
|
177
|
+
</button>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
{showSuggestions && filteredSuggestions.length > 0 && (
|
|
181
|
+
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-50 max-h-60 overflow-y-auto">
|
|
182
|
+
{filteredSuggestions.map((node, index) => {
|
|
183
|
+
const nodeName = getNodeDisplayName(node);
|
|
184
|
+
const nodeType = node.type || 'unknown';
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
key={node.id}
|
|
188
|
+
onClick={() => handleSuggestionClick(node)}
|
|
189
|
+
className={`px-4 py-2 cursor-pointer flex items-center justify-between hover:bg-gray-100 ${
|
|
190
|
+
index === selectedSuggestionIndex ? 'bg-purple-50' : ''
|
|
191
|
+
}`}
|
|
192
|
+
>
|
|
193
|
+
<span className="text-sm font-medium text-gray-900">{nodeName}</span>
|
|
194
|
+
<span className={`text-xs capitalize px-2 py-1 rounded ${getNodeTypeColorClass(nodeType)}`}>{nodeType}</span>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
VisualiserSearch.displayName = 'VisualiserSearch';
|
|
206
|
+
|
|
207
|
+
export default VisualiserSearch;
|