@checkstack/dependency-frontend 0.2.0

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.
@@ -0,0 +1,631 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ ReactFlow,
4
+ Background,
5
+ Controls,
6
+ MiniMap,
7
+ useNodesState,
8
+ useEdgesState,
9
+ useReactFlow,
10
+ ReactFlowProvider,
11
+ type NodeChange,
12
+ type Connection,
13
+ MarkerType,
14
+ Panel,
15
+ } from "@xyflow/react";
16
+ import "@xyflow/react/dist/style.css";
17
+
18
+ import { usePluginClient, wrapInSuspense } from "@checkstack/frontend-api";
19
+ import { useSignal } from "@checkstack/signal-frontend";
20
+ import { CatalogApi } from "@checkstack/catalog-common";
21
+ import { HealthCheckApi, SYSTEM_STATUS_CHANGED } from "@checkstack/healthcheck-common";
22
+ import {
23
+ DependencyApi,
24
+ DEPENDENCY_CHANGED,
25
+ DEPENDENCY_WARNINGS_CHANGED,
26
+ type Dependency,
27
+ type NodePosition,
28
+ } from "@checkstack/dependency-common";
29
+ import {
30
+ Button,
31
+ Badge,
32
+ LoadingSpinner,
33
+ useToast,
34
+ } from "@checkstack/ui";
35
+ import { Maximize2, Save, RefreshCw, Trash2 } from "lucide-react";
36
+ import type { ImpactType } from "@checkstack/dependency-common";
37
+ import { DependencyEdgeForm } from "./DependencyEdgeForm";
38
+
39
+ import {
40
+ SystemNodeComponent,
41
+ type SystemNode,
42
+ type SystemNodeData,
43
+ } from "./canvas/SystemNode";
44
+ import {
45
+ DependencyEdgeComponent,
46
+ type DependencyEdge,
47
+ type DependencyEdgeData,
48
+ } from "./canvas/DependencyEdge";
49
+
50
+ const nodeTypes = { system: SystemNodeComponent };
51
+ const edgeTypes = { dependency: DependencyEdgeComponent };
52
+
53
+ /**
54
+ * Auto-layout for nodes without saved positions.
55
+ * Places nodes in a grid pattern with reasonable spacing.
56
+ */
57
+ function autoLayout(
58
+ systemIds: string[],
59
+ savedPositions: NodePosition[],
60
+ ): Map<string, { x: number; y: number }> {
61
+ const posMap = new Map<string, { x: number; y: number }>();
62
+ const savedMap = new Map(savedPositions.map((p) => [p.systemId, p]));
63
+
64
+ const unpositioned = systemIds.filter((id) => !savedMap.has(id));
65
+ const cols = Math.ceil(Math.sqrt(unpositioned.length));
66
+ const spacingX = 250;
67
+ const spacingY = 120;
68
+
69
+ // Apply saved positions
70
+ for (const pos of savedPositions) {
71
+ posMap.set(pos.systemId, { x: pos.x, y: pos.y });
72
+ }
73
+
74
+ // Auto-position remaining systems
75
+ for (const [index, id] of unpositioned.entries()) {
76
+ const col = index % cols;
77
+ const row = Math.floor(index / cols);
78
+ posMap.set(id, {
79
+ x: col * spacingX + 100,
80
+ y: row * spacingY + 100,
81
+ });
82
+ }
83
+
84
+ return posMap;
85
+ }
86
+
87
+ function DependencyMapContent() {
88
+ const depClient = usePluginClient(DependencyApi);
89
+ const catalogClient = usePluginClient(CatalogApi);
90
+ const healthCheckClient = usePluginClient(HealthCheckApi);
91
+ const { fitView } = useReactFlow();
92
+ const [nodes, setNodes, onNodesChange] = useNodesState<SystemNode>([]);
93
+ const [edges, setEdges, onEdgesChange] = useEdgesState<DependencyEdge>([]);
94
+ const [hasUnsaved, setHasUnsaved] = useState(false);
95
+ const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
96
+
97
+ // Edge editor state
98
+ const [selectedEdge, setSelectedEdge] = useState<
99
+ | {
100
+ id: string;
101
+ sourceSystemId: string;
102
+ targetSystemId: string;
103
+ impactType: ImpactType;
104
+ transitive: boolean;
105
+ healthCheckRules: { healthCheckId: string; overrideImpactType: ImpactType }[];
106
+ }
107
+ | undefined
108
+ >();
109
+
110
+ // Fetch systems
111
+ const { data: systemsData, isLoading: systemsLoading } =
112
+ catalogClient.getSystems.useQuery({});
113
+
114
+ // Fetch all dependencies
115
+ const {
116
+ data: depsData,
117
+ isLoading: depsLoading,
118
+ refetch: refetchDeps,
119
+ } = depClient.getAllDependencies.useQuery();
120
+
121
+ // Fetch saved positions
122
+ const { data: posData, refetch: refetchPositions } =
123
+ depClient.getNodePositions.useQuery();
124
+
125
+ // Fetch warnings for all systems
126
+ const systemIds = useMemo(
127
+ () => systemsData?.systems.map((s) => s.id) ?? [],
128
+ [systemsData],
129
+ );
130
+
131
+ const { data: warningsData, refetch: refetchWarnings } =
132
+ depClient.getWarnings.useQuery(
133
+ { systemIds },
134
+ { enabled: systemIds.length > 0 },
135
+ );
136
+
137
+ // Fetch real health statuses for all systems
138
+ const { data: healthData, refetch: refetchHealth } =
139
+ healthCheckClient.getBulkSystemHealthStatus.useQuery(
140
+ { systemIds },
141
+ { enabled: systemIds.length > 0 },
142
+ );
143
+
144
+ // Save positions mutation
145
+ const saveMutation = depClient.saveNodePositions.useMutation({
146
+ onSuccess: () => {
147
+ setHasUnsaved(false);
148
+ },
149
+ });
150
+
151
+ // System name lookup
152
+ const systemNameMap = useMemo(() => {
153
+ const map = new Map<string, string>();
154
+ for (const s of systemsData?.systems ?? []) {
155
+ map.set(s.id, s.name);
156
+ }
157
+ return map;
158
+ }, [systemsData]);
159
+
160
+ // UUID regex for parsing cycle errors
161
+ const UUID_REGEX =
162
+ /[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}/gi;
163
+
164
+ // Create dependency mutation (for drag-to-connect)
165
+ const toast = useToast();
166
+ const createMutation = depClient.createDependency.useMutation({
167
+ onSuccess: () => {
168
+ toast.success("Dependency created");
169
+ void refetchDeps();
170
+ void refetchWarnings();
171
+ },
172
+ onError: (error) => {
173
+ const message =
174
+ error instanceof Error ? error.message : "Failed to create dependency";
175
+
176
+ // Check for cycle error and resolve names
177
+ if (message.includes("circular chain")) {
178
+ const ids = message.match(UUID_REGEX) ?? [];
179
+ const names = ids.map((id) => systemNameMap.get(id) ?? id.slice(0, 8));
180
+ toast.error(`Circular dependency: ${names.join(" → ")}`);
181
+ } else {
182
+ toast.error(message);
183
+ }
184
+ },
185
+ });
186
+
187
+ // Update dependency mutation (for edge editor)
188
+ const updateMutation = depClient.updateDependency.useMutation({
189
+ onSuccess: () => {
190
+ toast.success("Dependency updated");
191
+ setSelectedEdge(undefined);
192
+ void refetchDeps();
193
+ void refetchWarnings();
194
+ },
195
+ onError: (error) => {
196
+ toast.error(error instanceof Error ? error.message : "Failed to update");
197
+ },
198
+ });
199
+
200
+ // Delete dependency mutation (for edge editor)
201
+ const deleteMutation = depClient.deleteDependency.useMutation({
202
+ onSuccess: () => {
203
+ toast.success("Dependency deleted");
204
+ setSelectedEdge(undefined);
205
+ void refetchDeps();
206
+ void refetchWarnings();
207
+ },
208
+ onError: (error) => {
209
+ toast.error(error instanceof Error ? error.message : "Failed to delete");
210
+ },
211
+ });
212
+
213
+ // Handle edge connection (drag from source handle to target handle)
214
+ const { mutate: createDependency } = createMutation;
215
+ const onConnect = useCallback(
216
+ (connection: Connection) => {
217
+ if (!connection.source || !connection.target) return;
218
+ if (connection.source === connection.target) return;
219
+
220
+ createDependency({
221
+ sourceSystemId: connection.source,
222
+ targetSystemId: connection.target,
223
+ impactType: "degraded",
224
+ transitive: false,
225
+ });
226
+ },
227
+ [createDependency],
228
+ );
229
+
230
+ // Build graph from data
231
+ useEffect(() => {
232
+ if (!systemsData?.systems || !depsData?.dependencies) return;
233
+
234
+ const savedPositions = posData?.positions ?? [];
235
+ const positions = autoLayout(
236
+ systemsData.systems.map((s) => s.id),
237
+ savedPositions,
238
+ );
239
+
240
+ const warnings = warningsData?.warnings ?? {};
241
+ const healthStatuses = healthData?.statuses ?? {};
242
+
243
+ const newNodes: SystemNode[] = systemsData.systems.map((system) => {
244
+ const pos = positions.get(system.id) ?? { x: 0, y: 0 };
245
+ const warning = warnings[system.id];
246
+
247
+ // Map real health status to node status
248
+ const healthStatus = healthStatuses[system.id];
249
+ let selfStatus: "operational" | "degraded" | "down" = "operational";
250
+ if (healthStatus) {
251
+ if (healthStatus.status === "unhealthy") {
252
+ selfStatus = "down";
253
+ } else if (healthStatus.status === "degraded") {
254
+ selfStatus = "degraded";
255
+ }
256
+ }
257
+
258
+ const nodeData: SystemNodeData = {
259
+ label: system.name,
260
+ systemId: system.id,
261
+ status: selfStatus,
262
+ derivedState: warning?.derivedState,
263
+ };
264
+
265
+ return {
266
+ id: system.id,
267
+ type: "system" as const,
268
+ position: pos,
269
+ data: nodeData,
270
+ };
271
+ });
272
+
273
+ const newEdges: DependencyEdge[] = depsData.dependencies.map(
274
+ (dep: Dependency) => {
275
+ const edgeData: DependencyEdgeData = {
276
+ impactType: dep.impactType,
277
+ transitive: dep.transitive,
278
+ label: dep.label,
279
+ };
280
+
281
+ return {
282
+ id: dep.id,
283
+ source: dep.sourceSystemId,
284
+ target: dep.targetSystemId,
285
+ type: "dependency" as const,
286
+ animated: dep.transitive,
287
+ markerEnd: {
288
+ type: MarkerType.ArrowClosed,
289
+ width: 16,
290
+ height: 16,
291
+ },
292
+ data: edgeData,
293
+ };
294
+ },
295
+ );
296
+
297
+ setNodes(newNodes);
298
+ setEdges(newEdges);
299
+ }, [systemsData, depsData, posData, warningsData, healthData, setNodes, setEdges]);
300
+
301
+ // Track node position changes for saving
302
+ const nodesRef = useRef(nodes);
303
+ nodesRef.current = nodes;
304
+
305
+ const handleNodesChange = useCallback(
306
+ (changes: NodeChange<SystemNode>[]) => {
307
+ onNodesChange(changes);
308
+
309
+ // Check if any position changes happened
310
+ const hasPositionChange = changes.some(
311
+ (c) => c.type === "position" && c.dragging === false,
312
+ );
313
+
314
+ if (hasPositionChange) {
315
+ setHasUnsaved(true);
316
+
317
+ // Auto-save after 2 seconds of inactivity
318
+ if (saveTimeoutRef.current) {
319
+ clearTimeout(saveTimeoutRef.current);
320
+ }
321
+ saveTimeoutRef.current = setTimeout(() => {
322
+ handleSave();
323
+ }, 2000);
324
+ }
325
+ },
326
+ [onNodesChange],
327
+ );
328
+
329
+ // Save positions — reads from nodesRef to avoid stale closure
330
+ const { mutate: savePositions } = saveMutation;
331
+ const handleSave = useCallback(() => {
332
+ const positions: NodePosition[] = nodesRef.current.map((n) => ({
333
+ systemId: n.id,
334
+ x: Math.round(n.position.x),
335
+ y: Math.round(n.position.y),
336
+ }));
337
+
338
+ savePositions({ positions });
339
+ }, [savePositions]);
340
+
341
+ // Listen for realtime dependency changes
342
+ useSignal(DEPENDENCY_CHANGED, () => {
343
+ void refetchDeps();
344
+ });
345
+
346
+ useSignal(DEPENDENCY_WARNINGS_CHANGED, () => {
347
+ void refetchWarnings();
348
+ });
349
+
350
+ useSignal(SYSTEM_STATUS_CHANGED, () => {
351
+ void refetchHealth();
352
+ void refetchWarnings();
353
+ });
354
+
355
+ // Cleanup timeout on unmount
356
+ useEffect(() => {
357
+ return () => {
358
+ if (saveTimeoutRef.current) {
359
+ clearTimeout(saveTimeoutRef.current);
360
+ }
361
+ };
362
+ }, []);
363
+
364
+ const loading = systemsLoading || depsLoading;
365
+
366
+ if (loading) {
367
+ return (
368
+ <div className="h-[calc(100vh-12rem)] flex items-center justify-center">
369
+ <LoadingSpinner />
370
+ </div>
371
+ );
372
+ }
373
+
374
+ return (
375
+ <div className="h-[calc(100vh-12rem)] rounded-xl border border-border overflow-hidden bg-background/50">
376
+ <ReactFlow<SystemNode, DependencyEdge>
377
+ nodes={nodes}
378
+ edges={edges}
379
+ onNodesChange={handleNodesChange}
380
+ onEdgesChange={onEdgesChange}
381
+ onConnect={onConnect}
382
+ onEdgeClick={(_event, edge) => {
383
+ const dep = depsData?.dependencies.find((d) => d.id === edge.id);
384
+ if (dep) {
385
+ setSelectedEdge({
386
+ id: dep.id,
387
+ sourceSystemId: dep.sourceSystemId,
388
+ targetSystemId: dep.targetSystemId,
389
+ impactType: dep.impactType,
390
+ transitive: dep.transitive,
391
+ healthCheckRules:
392
+ dep.healthCheckRules?.map((r) => ({
393
+ healthCheckId: r.healthCheckId,
394
+ overrideImpactType: r.overrideImpactType,
395
+ })) ?? [],
396
+ });
397
+ }
398
+ }}
399
+ onPaneClick={() => setSelectedEdge(undefined)}
400
+ nodeTypes={nodeTypes}
401
+ edgeTypes={edgeTypes}
402
+ fitView
403
+ fitViewOptions={{ padding: 0.3 }}
404
+ minZoom={0.2}
405
+ maxZoom={2}
406
+ proOptions={{ hideAttribution: true }}
407
+ defaultEdgeOptions={{
408
+ type: "dependency",
409
+ }}
410
+ >
411
+ <Background gap={20} size={1} className="!bg-background" />
412
+ <Controls
413
+ showInteractive={false}
414
+ className="!bg-card !border-border !shadow-lg [&>button]:!bg-card [&>button]:!border-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
415
+ />
416
+ <MiniMap
417
+ className="!bg-card !border-border !shadow-lg"
418
+ nodeColor={(n) => {
419
+ const data = n.data as SystemNodeData;
420
+ const status = data.derivedState ?? data.status ?? "operational";
421
+ if (status === "down") return "rgb(239 68 68)";
422
+ if (status === "degraded") return "rgb(245 158 11)";
423
+ return "rgb(16 185 129)";
424
+ }}
425
+ maskColor="rgba(0, 0, 0, 0.2)"
426
+ />
427
+
428
+ {/* Top-right panel with actions */}
429
+ <Panel position="top-right" className="flex gap-2">
430
+ {hasUnsaved && (
431
+ <Badge variant="warning" className="animate-pulse">
432
+ Unsaved
433
+ </Badge>
434
+ )}
435
+ <Button
436
+ variant="outline"
437
+ size="sm"
438
+ onClick={handleSave}
439
+ disabled={saveMutation.isPending || !hasUnsaved}
440
+ className="bg-card/90 backdrop-blur-sm"
441
+ >
442
+ <Save className="h-4 w-4 mr-1" />
443
+ {saveMutation.isPending ? "Saving..." : "Save Layout"}
444
+ </Button>
445
+ <Button
446
+ variant="outline"
447
+ size="sm"
448
+ onClick={() => {
449
+ void refetchDeps();
450
+ void refetchWarnings();
451
+ void refetchPositions();
452
+ }}
453
+ className="bg-card/90 backdrop-blur-sm"
454
+ >
455
+ <RefreshCw className="h-4 w-4 mr-1" />
456
+ Refresh
457
+ </Button>
458
+ <Button
459
+ variant="outline"
460
+ size="sm"
461
+ onClick={() => fitView({ padding: 0.3 })}
462
+ className="bg-card/90 backdrop-blur-sm"
463
+ >
464
+ <Maximize2 className="h-4 w-4 mr-1" />
465
+ Fit
466
+ </Button>
467
+ </Panel>
468
+
469
+ {/* Bottom-left legend */}
470
+ <Panel position="bottom-left">
471
+ <div className="bg-card/90 backdrop-blur-sm border border-border rounded-lg p-3 shadow-lg max-w-64">
472
+ <p className="text-xs font-semibold text-muted-foreground mb-2 uppercase tracking-wider">
473
+ Impact Legend
474
+ </p>
475
+ <div className="space-y-1.5">
476
+ <div className="flex items-center gap-2">
477
+ <div className="w-6 h-0.5 bg-sky-400/60 rounded" />
478
+ <span className="text-xs text-muted-foreground">
479
+ Informational
480
+ </span>
481
+ </div>
482
+ <div className="flex items-center gap-2">
483
+ <div className="w-6 h-0.5 bg-amber-400/70 rounded" />
484
+ <span className="text-xs text-muted-foreground">Degraded</span>
485
+ </div>
486
+ <div className="flex items-center gap-2">
487
+ <div className="w-6 h-0.5 bg-red-400/80 rounded" />
488
+ <span className="text-xs text-muted-foreground">Critical</span>
489
+ </div>
490
+ <div className="flex items-center gap-2">
491
+ <div className="w-6 h-[1px] border-t border-dashed border-muted-foreground/50" />
492
+ <span className="text-xs text-muted-foreground">Multi-hop</span>
493
+ </div>
494
+ </div>
495
+ <details className="mt-2.5 group">
496
+ <summary className="text-xs text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors">
497
+ How multi-hop works
498
+ </summary>
499
+ <div className="mt-2 space-y-2 text-xs text-muted-foreground leading-relaxed">
500
+ <p>
501
+ A <strong className="text-foreground">multi-hop</strong> edge
502
+ looks through its target's own dependencies. A{" "}
503
+ <strong className="text-foreground">single-hop</strong> edge
504
+ only reacts to its direct target's status.
505
+ </p>
506
+ <div className="space-y-1 font-mono text-[11px]">
507
+ <p className="text-destructive">
508
+ A ⟶<sup>multi</sup> B ⟶ C<sub>down</sub> → A warned
509
+ </p>
510
+ <p className="text-emerald-400">
511
+ A ⟶<sup>single</sup> B ⟶ C<sub>down</sub> → A safe
512
+ </p>
513
+ </div>
514
+ <p>
515
+ B is operational in both cases. Multi-hop sees through B to
516
+ C's failure; single-hop only sees B directly.
517
+ </p>
518
+ </div>
519
+ </details>
520
+ </div>
521
+ </Panel>
522
+
523
+ {/* Edge editor panel */}
524
+ {selectedEdge && (
525
+ <Panel position="top-left">
526
+ <div className="bg-card/95 backdrop-blur-sm border border-border rounded-lg shadow-lg p-4 w-72 space-y-3">
527
+ <div className="space-y-1">
528
+ <p className="text-sm font-semibold text-foreground">
529
+ Edit Dependency
530
+ </p>
531
+ <p className="text-xs text-muted-foreground">
532
+ {systemNameMap.get(selectedEdge.sourceSystemId) ??
533
+ selectedEdge.sourceSystemId}
534
+ {" → "}
535
+ {systemNameMap.get(selectedEdge.targetSystemId) ??
536
+ selectedEdge.targetSystemId}
537
+ </p>
538
+ </div>
539
+ <DependencyEdgeForm
540
+ impactType={selectedEdge.impactType}
541
+ onImpactTypeChange={(impactType) =>
542
+ setSelectedEdge({ ...selectedEdge, impactType })
543
+ }
544
+ transitive={selectedEdge.transitive}
545
+ onTransitiveChange={(transitive) =>
546
+ setSelectedEdge({ ...selectedEdge, transitive })
547
+ }
548
+ targetSystemId={selectedEdge.targetSystemId}
549
+ healthCheckRules={selectedEdge.healthCheckRules}
550
+ onHealthCheckRulesChange={(rules) =>
551
+ setSelectedEdge({ ...selectedEdge, healthCheckRules: rules })
552
+ }
553
+ compact
554
+ />
555
+ <div className="flex gap-2 justify-between">
556
+ <Button
557
+ type="button"
558
+ variant="destructive"
559
+ size="sm"
560
+ onClick={() =>
561
+ deleteMutation.mutate({
562
+ id: selectedEdge.id,
563
+ systemId: selectedEdge.sourceSystemId,
564
+ })
565
+ }
566
+ disabled={deleteMutation.isPending}
567
+ >
568
+ <Trash2 className="h-3.5 w-3.5 mr-1" />
569
+ Delete
570
+ </Button>
571
+ <div className="flex gap-2">
572
+ <Button
573
+ type="button"
574
+ variant="ghost"
575
+ size="sm"
576
+ onClick={() => setSelectedEdge(undefined)}
577
+ >
578
+ Cancel
579
+ </Button>
580
+ <Button
581
+ type="button"
582
+ size="sm"
583
+ onClick={() =>
584
+ updateMutation.mutate({
585
+ id: selectedEdge.id,
586
+ systemId: selectedEdge.sourceSystemId,
587
+ impactType: selectedEdge.impactType,
588
+ transitive: selectedEdge.transitive,
589
+ healthCheckRules:
590
+ selectedEdge.healthCheckRules.length > 0
591
+ ? selectedEdge.healthCheckRules
592
+ : [],
593
+ })
594
+ }
595
+ disabled={updateMutation.isPending}
596
+ >
597
+ {updateMutation.isPending ? "Saving..." : "Save"}
598
+ </Button>
599
+ </div>
600
+ </div>
601
+ </div>
602
+ </Panel>
603
+ )}
604
+ </ReactFlow>
605
+ </div>
606
+ );
607
+ }
608
+
609
+ /**
610
+ * Dependency Map page — wrapped in ReactFlowProvider and Suspense.
611
+ */
612
+ const DependencyMapPageContent = () => {
613
+ return (
614
+ <div className="space-y-4">
615
+ <div className="flex items-center justify-between">
616
+ <div>
617
+ <h1 className="text-2xl font-bold text-foreground">Dependency Map</h1>
618
+ <p className="text-sm text-muted-foreground mt-1">
619
+ Interactive topology view of system dependencies. Drag nodes to
620
+ rearrange — positions auto-save.
621
+ </p>
622
+ </div>
623
+ </div>
624
+ <ReactFlowProvider>
625
+ <DependencyMapContent />
626
+ </ReactFlowProvider>
627
+ </div>
628
+ );
629
+ };
630
+
631
+ export const DependencyMapPage = wrapInSuspense(DependencyMapPageContent);
@@ -0,0 +1,20 @@
1
+
2
+ import { Link } from "react-router-dom";
3
+ import { GitBranch } from "lucide-react";
4
+ import type { UserMenuItemsContext } from "@checkstack/frontend-api";
5
+ import { DropdownMenuItem } from "@checkstack/ui";
6
+ import { resolveRoute } from "@checkstack/common";
7
+ import { dependencyRoutes } from "@checkstack/dependency-common";
8
+
9
+ /**
10
+ * Adds a "Dependency Map" link to the user menu.
11
+ */
12
+ export const DependencyMenuItems = (_props: UserMenuItemsContext) => {
13
+ return (
14
+ <Link to={resolveRoute(dependencyRoutes.routes.map)}>
15
+ <DropdownMenuItem icon={<GitBranch className="h-4 w-4" />}>
16
+ Dependency Map
17
+ </DropdownMenuItem>
18
+ </Link>
19
+ );
20
+ };