@eventcatalog/core 4.9.0 → 4.10.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-HKVOSQTD.js → chunk-OC2RGFPU.js} +1 -1
- package/dist/{chunk-MPHX5C7M.js → chunk-PHG7KEUH.js} +1 -1
- package/dist/{chunk-LVGQ23LR.js → chunk-QIV7LWVJ.js} +1 -1
- package/dist/{chunk-XQHS36GO.js → chunk-SQNVL7PI.js} +1 -1
- package/dist/{chunk-3CGSSMTK.js → chunk-T2HLSITU.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/astro.config.mjs +6 -0
- package/eventcatalog/src/components/Badge.astro +3 -1
- package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +260 -175
- package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +0 -5
- package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraph.astro +62 -0
- package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraphPortal.tsx +22 -0
- package/eventcatalog/src/components/MDX/ArchitectureGraph/AstroArchitectureGraph.tsx +42 -0
- package/eventcatalog/src/components/MDX/Design/Design.astro +2 -0
- package/eventcatalog/src/components/MDX/EntityMap/EntityMap.astro +2 -0
- package/eventcatalog/src/components/MDX/Flow/Flow.astro +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +5 -7
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -0
- package/eventcatalog/src/components/MDX/components.tsx +13 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +121 -88
- package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +55 -7
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.spec.ts +59 -15
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +53 -7
- package/eventcatalog/src/content.config.ts +70 -0
- package/eventcatalog/src/enterprise/collections/resource-docs-utils.ts +19 -0
- package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +46 -1
- package/eventcatalog/src/enterprise/fields/pages/fields.astro +2 -0
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +16 -15
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +21 -17
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +45 -4
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +16 -8
- package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +48 -65
- package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +45 -18
- package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -1
- package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -1
- package/eventcatalog/src/pages/index.astro +34 -2
- package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +5 -1
- package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +2 -0
- package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -0
- package/eventcatalog/src/pages/visualiser/graph/index.astro +2 -23
- package/eventcatalog/src/stores/sidebar-store/builders/adr.ts +96 -34
- package/eventcatalog/src/stores/sidebar-store/builders/agent.ts +167 -75
- package/eventcatalog/src/stores/sidebar-store/builders/container.ts +135 -59
- package/eventcatalog/src/stores/sidebar-store/builders/data-product.ts +144 -58
- package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +306 -188
- package/eventcatalog/src/stores/sidebar-store/builders/entity.ts +108 -39
- package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +147 -63
- package/eventcatalog/src/stores/sidebar-store/builders/message.ts +212 -121
- package/eventcatalog/src/stores/sidebar-store/builders/service.ts +223 -127
- package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +3 -23
- package/eventcatalog/src/stores/sidebar-store/builders/system.ts +187 -97
- package/eventcatalog/src/stores/sidebar-store/custom-sidebar.ts +678 -0
- package/eventcatalog/src/stores/sidebar-store/state.ts +66 -36
- package/eventcatalog/src/styles/tailwind.css +82 -0
- package/eventcatalog/src/utils/collections/resource-docs.ts +1 -0
- package/eventcatalog/src/utils/node-graphs/catalog-force-graph.ts +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import CatalogForceGraph from '@components/CatalogGraph/CatalogForceGraph';
|
|
4
|
+
import type { CatalogGraphWireLink, CatalogGraphWireNode } from '@utils/node-graphs/catalog-force-graph';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** DOM id of the placeholder div ArchitectureGraphPortal rendered into the MDX content */
|
|
8
|
+
portalId: string;
|
|
9
|
+
nodes: CatalogGraphWireNode[];
|
|
10
|
+
links: CatalogGraphWireLink[];
|
|
11
|
+
linkLabels: string[];
|
|
12
|
+
initialLens?: string;
|
|
13
|
+
initialFocus?: string;
|
|
14
|
+
initialFocusDepth?: number;
|
|
15
|
+
showSearch?: boolean;
|
|
16
|
+
showLegend?: boolean;
|
|
17
|
+
showLensPicker?: boolean;
|
|
18
|
+
/** Base URL of the standalone /visualiser/graph page — the graph renders an
|
|
19
|
+
* "open full screen" link carrying its live view state (focus, depth, lens) */
|
|
20
|
+
href?: string;
|
|
21
|
+
hrefLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The island mounts outside the MDX content, finds the placeholder div the MDX
|
|
25
|
+
// component map emitted, and portals the graph into it — same pattern as the
|
|
26
|
+
// visualiser's NodeGraph.
|
|
27
|
+
const AstroArchitectureGraph = ({ portalId, href, hrefLabel, ...graphProps }: Props) => {
|
|
28
|
+
const [container, setContainer] = useState<HTMLElement | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setContainer(document.getElementById(portalId));
|
|
32
|
+
}, [portalId]);
|
|
33
|
+
|
|
34
|
+
if (!container) return null;
|
|
35
|
+
|
|
36
|
+
return createPortal(
|
|
37
|
+
<CatalogForceGraph {...graphProps} syncUrl={false} zoomOnScroll={false} openInGraphUrl={href} openInGraphLabel={hrefLabel} />,
|
|
38
|
+
container
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default AstroArchitectureGraph;
|
|
@@ -4,6 +4,8 @@ import fs from 'fs';
|
|
|
4
4
|
import { getAbsoluteFilePathForAstroFile } from '@utils/files';
|
|
5
5
|
import Admonition from '@components/MDX/Admonition';
|
|
6
6
|
import AstroNodeGraph from '../NodeGraph/AstroNodeGraph';
|
|
7
|
+
// Visualiser styles ship in the page head so ClientRouter navigations keep them (see NodeGraph.astro).
|
|
8
|
+
import '@eventcatalog/visualiser/styles-core.css';
|
|
7
9
|
|
|
8
10
|
import { isVisualiserEnabled, isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
|
|
9
11
|
import { loadSavedLayout, applyLayoutToNodes, buildResourceKey } from '@utils/node-graphs/layout-persistence';
|
|
@@ -3,6 +3,8 @@ import { getDomains } from '@utils/collections/domains';
|
|
|
3
3
|
import { getNodesAndEdges } from '@utils/node-graphs/domain-entity-map.ts';
|
|
4
4
|
import Admonition from '@components/MDX/Admonition';
|
|
5
5
|
import AstroNodeGraph from '../NodeGraph/AstroNodeGraph';
|
|
6
|
+
// Visualiser styles ship in the page head so ClientRouter navigations keep them (see NodeGraph.astro).
|
|
7
|
+
import '@eventcatalog/visualiser/styles-core.css';
|
|
6
8
|
import { getVersionFromCollection } from '@utils/collections/versions';
|
|
7
9
|
import { getServices } from '@utils/collections/services';
|
|
8
10
|
import { isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
|
|
@@ -3,6 +3,8 @@ import { getFlows } from '@utils/collections/flows';
|
|
|
3
3
|
import { getNodesAndEdges } from '@utils/node-graphs/flows-node-graph';
|
|
4
4
|
import Admonition from '@components/MDX/Admonition';
|
|
5
5
|
import AstroNodeGraph from '../NodeGraph/AstroNodeGraph';
|
|
6
|
+
// Visualiser styles ship in the page head so ClientRouter navigations keep them (see NodeGraph.astro).
|
|
7
|
+
import '@eventcatalog/visualiser/styles-core.css';
|
|
6
8
|
import { getVersionFromCollection } from '@utils/collections/versions';
|
|
7
9
|
import { isVisualiserEnabled, isEventCatalogChatEnabled, isDevMode } from '@utils/feature';
|
|
8
10
|
import { loadSavedLayout, applyLayoutToNodes, buildResourceKey } from '@utils/node-graphs/layout-persistence';
|
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* - Navigation using Astro's navigate function
|
|
7
7
|
* - Layout persistence via Astro API routes
|
|
8
8
|
* - URL building with Astro's URL utilities
|
|
9
|
+
*
|
|
10
|
+
* Note: the visualiser stylesheet is deliberately NOT imported here. Every Astro file that
|
|
11
|
+
* renders this component imports `@eventcatalog/visualiser/styles-core.css` itself so the
|
|
12
|
+
* CSS ships in the page head and survives ClientRouter navigations (see NodeGraph.astro).
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import { useCallback, lazy, Suspense
|
|
15
|
+
import { useCallback, lazy, Suspense } from 'react';
|
|
12
16
|
import type { Node, Edge } from '@xyflow/react';
|
|
13
17
|
import { buildUrl } from '@utils/url-builder';
|
|
14
18
|
|
|
@@ -42,12 +46,6 @@ interface AstroNodeGraphProps {
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const AstroNodeGraph = ({ isDevMode = false, resourceKey, ...otherProps }: AstroNodeGraphProps) => {
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
// Load visualizer styles only when this component is mounted.
|
|
47
|
-
// Use the core-scoped stylesheet so visualizer styles don't override EventCatalog utility classes globally.
|
|
48
|
-
import('@eventcatalog/visualiser/styles-core.css');
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
49
|
// Astro-specific navigation handler
|
|
52
50
|
const handleNavigate = useCallback((url: string) => {
|
|
53
51
|
// Use window.location for navigation since we can't import astro:transitions/client in a React component
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
import AstroNodeGraph from './AstroNodeGraph';
|
|
3
|
+
// Imported here (server side) rather than from the React island: Astro then emits it as a
|
|
4
|
+
// <link> in the page head, which survives ClientRouter navigations. A dynamic import from the
|
|
5
|
+
// island injects a <style> that the view-transition head swap removes — and the cached module
|
|
6
|
+
// never re-injects it, leaving the graph unstyled until a full reload.
|
|
7
|
+
import '@eventcatalog/visualiser/styles-core.css';
|
|
3
8
|
import { getNodesAndEdges as getNodesAndEdgesForAgent } from '@utils/node-graphs/agents-node-graph';
|
|
4
9
|
import { getNodesAndEdges as getNodesAndEdgesForService } from '@utils/node-graphs/services-node-graph';
|
|
5
10
|
import {
|
|
@@ -35,6 +35,7 @@ import MermaidFileLoader from '@components/MDX/MermaidFileLoader/MermaidFileLoad
|
|
|
35
35
|
import LikeC4View from '@components/MDX/LikeC4View/LikeC4View.astro';
|
|
36
36
|
// Portals: required for server/client components
|
|
37
37
|
import NodeGraphPortal from '@components/MDX/NodeGraph/NodeGraphPortal';
|
|
38
|
+
import ArchitectureGraphPortal from '@components/MDX/ArchitectureGraph/ArchitectureGraphPortal';
|
|
38
39
|
import ContextDiagramPortal from '@components/MDX/ContextDiagram/ContextDiagramPortal';
|
|
39
40
|
import SystemContextMapPortal from '@components/MDX/SystemContextMap/SystemContextMapPortal';
|
|
40
41
|
import SchemaViewerPortal from '@components/MDX/SchemaViewer/SchemaViewerPortal';
|
|
@@ -50,6 +51,10 @@ const getEntityMapCollection = (props: any, mdxProp: any) => {
|
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
const components = (props: any) => {
|
|
54
|
+
// Occurrence counter so multiple <ArchitectureGraph/> tags on one page each get
|
|
55
|
+
// their own portal — MDX renders in document order, matching the body scan order
|
|
56
|
+
// the host pages use to render the islands.
|
|
57
|
+
let architectureGraphOccurrence = 0;
|
|
53
58
|
return {
|
|
54
59
|
Attachments: (mdxProp: any) => jsx(Attachments, { ...props, ...mdxProp }),
|
|
55
60
|
CustomProperties: (mdxProp: any) => jsx(CustomProperties, { ...props, ...mdxProp }),
|
|
@@ -69,6 +74,14 @@ const components = (props: any) => {
|
|
|
69
74
|
ADRTable: (mdxProp: any) => jsx(ADRTable, { ...props, ...mdxProp }),
|
|
70
75
|
EntityPropertiesTable: (mdxProp: any) => jsx(EntityPropertiesTable, { ...props, ...mdxProp }),
|
|
71
76
|
NodeGraph: (mdxProp: any) => jsx(NodeGraphPortal, { ...props.data, ...mdxProp, props, mdxProp }),
|
|
77
|
+
ArchitectureGraph: (mdxProp: any) =>
|
|
78
|
+
jsx(ArchitectureGraphPortal, {
|
|
79
|
+
...props.data,
|
|
80
|
+
...mdxProp,
|
|
81
|
+
occurrenceIndex: architectureGraphOccurrence++,
|
|
82
|
+
props,
|
|
83
|
+
mdxProp,
|
|
84
|
+
}),
|
|
72
85
|
ComponentDiagram: (mdxProp: any) => jsx(NodeGraphPortal, { ...props.data, ...mdxProp, props, mdxProp }),
|
|
73
86
|
ContextDiagram: (mdxProp: any) => jsx(ContextDiagramPortal, { ...props.data, ...mdxProp, props, mdxProp }),
|
|
74
87
|
SystemContextMap: (mdxProp: any) => jsx(SystemContextMapPortal, { ...mdxProp }),
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
4
4
|
import * as LucideIcons from 'lucide-react';
|
|
5
5
|
import { ChevronRight, ChevronLeft, ChevronDown, Home, Star } from 'lucide-react';
|
|
6
6
|
import type { NavNode, ChildRef } from '@stores/sidebar-store/state';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
saveState,
|
|
9
|
+
loadState,
|
|
10
|
+
saveCollapsedSections,
|
|
11
|
+
loadCollapsedSections,
|
|
12
|
+
saveScrollPosition,
|
|
13
|
+
loadScrollPosition,
|
|
14
|
+
} from './storage';
|
|
8
15
|
import { useStore } from '@nanostores/react';
|
|
9
16
|
import { sidebarStore } from '@stores/sidebar-store';
|
|
10
17
|
import {
|
|
@@ -13,7 +20,14 @@ import {
|
|
|
13
20
|
removeFavorite as removeFavoriteAction,
|
|
14
21
|
type FavoriteItem,
|
|
15
22
|
} from '@stores/favorites-store';
|
|
16
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
canCollapseGroup,
|
|
25
|
+
findNodeKeyByUrl,
|
|
26
|
+
getBadgeClasses,
|
|
27
|
+
getDefaultCollapsedState,
|
|
28
|
+
isGroupCollapsed,
|
|
29
|
+
toggleGroupCollapsed,
|
|
30
|
+
} from './utils';
|
|
17
31
|
import { resolveIconUrl } from '@utils/icon';
|
|
18
32
|
|
|
19
33
|
const cn = (...classes: (string | false | undefined)[]) => classes.filter(Boolean).join(' ');
|
|
@@ -60,6 +74,7 @@ export default function NestedSideBar() {
|
|
|
60
74
|
const [sectionCollapsePreferences, setSectionCollapsePreferences] = useState(loadCollapsedSections);
|
|
61
75
|
const [showPathPreview, setShowPathPreview] = useState(false);
|
|
62
76
|
const [showFullPath, setShowFullPath] = useState(false);
|
|
77
|
+
const navRef = useRef<HTMLElement | null>(null);
|
|
63
78
|
|
|
64
79
|
// Build a lookup map for faster URL navigation
|
|
65
80
|
// Map format: "type:id" -> "nodeKey"
|
|
@@ -94,19 +109,9 @@ export default function NestedSideBar() {
|
|
|
94
109
|
/**
|
|
95
110
|
* Toggle section collapse state
|
|
96
111
|
*/
|
|
97
|
-
const toggleSectionCollapse = (sectionId: string) => {
|
|
112
|
+
const toggleSectionCollapse = (sectionId: string, defaultCollapsed = true) => {
|
|
98
113
|
setSectionCollapsePreferences((previousPreferences) => {
|
|
99
|
-
const nextPreferences =
|
|
100
|
-
expanded: new Set(previousPreferences.expanded),
|
|
101
|
-
};
|
|
102
|
-
const isCurrentlyCollapsed = isGroupCollapsed(true, sectionId, previousPreferences);
|
|
103
|
-
|
|
104
|
-
if (isCurrentlyCollapsed) {
|
|
105
|
-
nextPreferences.expanded.add(sectionId);
|
|
106
|
-
} else {
|
|
107
|
-
nextPreferences.expanded.delete(sectionId);
|
|
108
|
-
}
|
|
109
|
-
|
|
114
|
+
const nextPreferences = toggleGroupCollapsed(sectionId, previousPreferences, defaultCollapsed);
|
|
110
115
|
saveCollapsedSections(nextPreferences);
|
|
111
116
|
return nextPreferences;
|
|
112
117
|
});
|
|
@@ -416,6 +421,50 @@ export default function NestedSideBar() {
|
|
|
416
421
|
};
|
|
417
422
|
}, [isInitialized, findAndNavigateToUrl, currentPath]);
|
|
418
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Keep the selected item visible. On a full page load the nav remounts scrolled to the
|
|
426
|
+
* top, so first restore the offset saved for this drill-down path, then nudge the
|
|
427
|
+
* active item into view if it still isn't. `block: 'nearest'` never scrolls when the
|
|
428
|
+
* item is already visible, so clicking around near the top doesn't jump.
|
|
429
|
+
*/
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (!isInitialized) return;
|
|
432
|
+
const nav = navRef.current;
|
|
433
|
+
if (!nav) return;
|
|
434
|
+
|
|
435
|
+
const pathKey = getCurrentPath().join('/') || 'root';
|
|
436
|
+
const savedScrollTop = loadScrollPosition(pathKey);
|
|
437
|
+
if (savedScrollTop !== null) nav.scrollTop = savedScrollTop;
|
|
438
|
+
|
|
439
|
+
const active = nav.querySelector<HTMLElement>('[data-active="true"]');
|
|
440
|
+
if (!active) return;
|
|
441
|
+
|
|
442
|
+
const navRect = nav.getBoundingClientRect();
|
|
443
|
+
const activeRect = active.getBoundingClientRect();
|
|
444
|
+
const isVisible = activeRect.top >= navRect.top && activeRect.bottom <= navRect.bottom;
|
|
445
|
+
if (!isVisible) active.scrollIntoView({ block: 'nearest' });
|
|
446
|
+
}, [isInitialized, currentPath, navigationStack, getCurrentPath]);
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Persist the nav scroll offset as it changes.
|
|
450
|
+
*/
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
if (!isInitialized) return;
|
|
453
|
+
const nav = navRef.current;
|
|
454
|
+
if (!nav) return;
|
|
455
|
+
|
|
456
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
457
|
+
const onScroll = () => {
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
timer = setTimeout(() => saveScrollPosition(getCurrentPath().join('/') || 'root', nav.scrollTop), 150);
|
|
460
|
+
};
|
|
461
|
+
nav.addEventListener('scroll', onScroll, { passive: true });
|
|
462
|
+
return () => {
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
nav.removeEventListener('scroll', onScroll);
|
|
465
|
+
};
|
|
466
|
+
}, [isInitialized, navigationStack, getCurrentPath]);
|
|
467
|
+
|
|
419
468
|
/**
|
|
420
469
|
* Check if a node is favorited
|
|
421
470
|
* Note: This hook must be defined before any early returns to comply with Rules of Hooks
|
|
@@ -658,14 +707,15 @@ export default function NestedSideBar() {
|
|
|
658
707
|
};
|
|
659
708
|
|
|
660
709
|
/**
|
|
661
|
-
* Render a group with its children
|
|
710
|
+
* Render a group with its children.
|
|
711
|
+
*
|
|
712
|
+
* Groups render as a docs-style tree: every group has its caret on the right; top-level
|
|
713
|
+
* sections also carry an icon tile. Each level's children hang off a vertical guide.
|
|
662
714
|
*/
|
|
663
|
-
const renderGroup = (group: NavNode, groupKey: string | null, index: number) => {
|
|
664
|
-
// Get optional icon for group
|
|
715
|
+
const renderGroup = (group: NavNode, groupKey: string | null, index: number, depth = 0) => {
|
|
665
716
|
const GroupIcon = group.icon ? (LucideIcons as unknown as Record<string, LucideIcons.LucideIcon>)[group.icon] : null;
|
|
666
|
-
const
|
|
717
|
+
const isNested = depth > 0;
|
|
667
718
|
|
|
668
|
-
// Get visible children
|
|
669
719
|
const visibleChildren =
|
|
670
720
|
group.pages?.filter((childRef) => {
|
|
671
721
|
const child = resolveRef(childRef);
|
|
@@ -673,46 +723,36 @@ export default function NestedSideBar() {
|
|
|
673
723
|
}) ?? [];
|
|
674
724
|
|
|
675
725
|
const groupId = groupKey || group.collapseKey || `${currentLevel.key ?? 'root'}:group:${group.title}`;
|
|
676
|
-
const canCollapse = canCollapseGroup(
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
// they render flush under the parent icon instead of inside the indented border guide.
|
|
681
|
-
const hasSubtleChildren = visibleChildren.some((childRef) => {
|
|
682
|
-
const child = resolveRef(childRef);
|
|
683
|
-
return child && isGroup(child) && child.subtle === true;
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
const headerContent = (
|
|
726
|
+
const canCollapse = canCollapseGroup(isTopLevel, group.collapsible, group.collapsed);
|
|
727
|
+
const defaultCollapsed = getDefaultCollapsedState(visibleChildren.length, group.collapsed);
|
|
728
|
+
const isCollapsed = isGroupCollapsed(canCollapse, groupId, sectionCollapsePreferences, defaultCollapsed);
|
|
729
|
+
const header = isNested ? (
|
|
687
730
|
<>
|
|
688
|
-
<
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
'flex items-center justify-center w-5 h-5 rounded',
|
|
693
|
-
isSubtleGroup
|
|
694
|
-
? 'bg-[rgb(var(--ec-content-hover))] text-[rgb(var(--ec-content-text-muted))]'
|
|
695
|
-
: 'bg-[rgb(var(--ec-group-icon-bg))] text-[rgb(var(--ec-group-icon-text))]'
|
|
696
|
-
)}
|
|
697
|
-
>
|
|
698
|
-
<GroupIcon className="w-3 h-3" />
|
|
699
|
-
</span>
|
|
700
|
-
)}
|
|
701
|
-
<span
|
|
731
|
+
{GroupIcon && <GroupIcon className="w-3.5 h-3.5 flex-shrink-0 text-[rgb(var(--ec-content-text-muted))]" />}
|
|
732
|
+
<span className="text-[12px] font-medium text-[rgb(var(--ec-content-text))] truncate">{group.title}</span>
|
|
733
|
+
{canCollapse && (
|
|
734
|
+
<ChevronDown
|
|
702
735
|
className={cn(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
: 'text-[12px] font-semibold tracking-tight text-[rgb(var(--ec-content-text))]'
|
|
736
|
+
'ml-auto w-3.5 h-3.5 flex-shrink-0 text-[rgb(var(--ec-icon-color))] transition-transform',
|
|
737
|
+
isCollapsed && '-rotate-90'
|
|
706
738
|
)}
|
|
707
|
-
|
|
708
|
-
|
|
739
|
+
/>
|
|
740
|
+
)}
|
|
741
|
+
</>
|
|
742
|
+
) : (
|
|
743
|
+
<>
|
|
744
|
+
{GroupIcon && (
|
|
745
|
+
<span className="flex items-center justify-center w-5 h-5 flex-shrink-0 rounded bg-[rgb(var(--ec-group-icon-bg))] text-[rgb(var(--ec-group-icon-text))]">
|
|
746
|
+
<GroupIcon className="w-3 h-3" />
|
|
709
747
|
</span>
|
|
710
|
-
|
|
748
|
+
)}
|
|
749
|
+
<span className="text-[12px] font-semibold tracking-tight text-[rgb(var(--ec-content-text))] truncate">
|
|
750
|
+
{group.title}
|
|
751
|
+
</span>
|
|
711
752
|
{canCollapse && (
|
|
712
753
|
<ChevronDown
|
|
713
754
|
className={cn(
|
|
714
|
-
|
|
715
|
-
'text-[rgb(var(--ec-icon-color))] transition-transform',
|
|
755
|
+
'ml-auto w-4 h-4 flex-shrink-0 text-[rgb(var(--ec-icon-color))] transition-transform',
|
|
716
756
|
isCollapsed && '-rotate-90'
|
|
717
757
|
)}
|
|
718
758
|
/>
|
|
@@ -720,32 +760,30 @@ export default function NestedSideBar() {
|
|
|
720
760
|
</>
|
|
721
761
|
);
|
|
722
762
|
|
|
763
|
+
const headerClasses = cn(
|
|
764
|
+
'group flex items-center w-full rounded-md text-left transition-colors',
|
|
765
|
+
isNested ? 'gap-1.5 px-2 py-1.5' : 'gap-2 px-2 py-1.5',
|
|
766
|
+
canCollapse && 'cursor-pointer hover:bg-[rgb(var(--ec-content-hover))]'
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
// Top level: the guide line sits under the centre of the icon tile (8px padding + half of
|
|
770
|
+
// 20px). Nested: carets sit on the right, so the guide is simply inset from the label.
|
|
771
|
+
const childrenClasses = cn(
|
|
772
|
+
'flex flex-col gap-px mt-0.5 border-l border-[rgb(var(--ec-content-border))]',
|
|
773
|
+
isNested ? 'ml-3 pl-1.5' : 'ml-[18px] pl-2'
|
|
774
|
+
);
|
|
775
|
+
|
|
723
776
|
return (
|
|
724
|
-
<div key={`group-${groupKey || index}`} className={cn(
|
|
777
|
+
<div key={`group-${groupKey || index}`} className={cn(isNested ? 'mt-0.5' : 'mb-4 last:mb-1')}>
|
|
725
778
|
{canCollapse ? (
|
|
726
|
-
<button
|
|
727
|
-
|
|
728
|
-
className={cn(
|
|
729
|
-
'flex items-center justify-between w-full rounded-md transition-colors cursor-pointer',
|
|
730
|
-
isSubtleGroup
|
|
731
|
-
? 'px-2 py-1 hover:bg-[rgb(var(--ec-content-hover))]/60'
|
|
732
|
-
: 'px-2 py-1.5 hover:bg-[rgb(var(--ec-content-hover))]'
|
|
733
|
-
)}
|
|
734
|
-
>
|
|
735
|
-
{headerContent}
|
|
779
|
+
<button type="button" onClick={() => toggleSectionCollapse(groupId, defaultCollapsed)} className={headerClasses}>
|
|
780
|
+
{header}
|
|
736
781
|
</button>
|
|
737
782
|
) : (
|
|
738
|
-
<div className={
|
|
739
|
-
{headerContent}
|
|
740
|
-
</div>
|
|
783
|
+
<div className={headerClasses}>{header}</div>
|
|
741
784
|
)}
|
|
742
785
|
{!isCollapsed && (
|
|
743
|
-
<div
|
|
744
|
-
className={cn(
|
|
745
|
-
'flex flex-col gap-0.5 border-[rgb(var(--ec-content-border))]',
|
|
746
|
-
isSubtleGroup ? 'border-l ml-4 mt-1' : hasSubtleChildren ? 'mt-1' : 'border-l ml-4 mt-1'
|
|
747
|
-
)}
|
|
748
|
-
>
|
|
786
|
+
<div className={childrenClasses}>
|
|
749
787
|
{visibleChildren.map((childRef, childIndex) => {
|
|
750
788
|
const child = resolveRef(childRef);
|
|
751
789
|
if (!child) return null;
|
|
@@ -753,22 +791,13 @@ export default function NestedSideBar() {
|
|
|
753
791
|
const childKey = typeof childRef === 'string' ? childRef : null;
|
|
754
792
|
|
|
755
793
|
if (isGroup(child)) {
|
|
756
|
-
// Skip nested groups with no visible children
|
|
757
794
|
if (!hasVisibleChildren(child)) return null;
|
|
758
|
-
|
|
759
|
-
return (
|
|
760
|
-
<div
|
|
761
|
-
key={`nested-group-${childKey || childIndex}`}
|
|
762
|
-
className={cn(child.subtle ? 'mt-1' : 'ml-3 mt-1.5 pl-3 border-l border-[rgb(var(--ec-content-border))]')}
|
|
763
|
-
>
|
|
764
|
-
{renderGroup(child, childKey, childIndex)}
|
|
765
|
-
</div>
|
|
766
|
-
);
|
|
795
|
+
return renderGroup(child, childKey, childIndex, depth + 1);
|
|
767
796
|
}
|
|
768
|
-
|
|
769
|
-
//
|
|
770
|
-
// per-
|
|
771
|
-
return renderItem(child, childKey, childIndex,
|
|
797
|
+
|
|
798
|
+
// Under a nested group the header already conveys the resource type, so the
|
|
799
|
+
// default per-collection glyph is dropped; custom icons still show.
|
|
800
|
+
return renderItem(child, childKey, childIndex, isNested);
|
|
772
801
|
})}
|
|
773
802
|
</div>
|
|
774
803
|
)}
|
|
@@ -845,7 +874,7 @@ export default function NestedSideBar() {
|
|
|
845
874
|
);
|
|
846
875
|
|
|
847
876
|
const baseClasses =
|
|
848
|
-
'group flex items-center justify-between w-full px-
|
|
877
|
+
'group flex items-center justify-between w-full px-2 py-1.5 rounded-md cursor-pointer text-left transition-colors hover:bg-[rgb(var(--ec-content-hover))] active:bg-[rgb(var(--ec-content-hover))]';
|
|
849
878
|
const parentClasses = itemHasChildren ? 'font-medium' : '';
|
|
850
879
|
const activeClasses = isActive ? 'bg-[rgb(var(--ec-rail-active-bg))] hover:bg-[rgb(var(--ec-rail-active-bg))]' : '';
|
|
851
880
|
|
|
@@ -857,6 +886,8 @@ export default function NestedSideBar() {
|
|
|
857
886
|
href={item.href}
|
|
858
887
|
title={item.title}
|
|
859
888
|
target={item.external ? '_blank' : undefined}
|
|
889
|
+
aria-current={isActive ? 'page' : undefined}
|
|
890
|
+
data-active={isActive ? 'true' : undefined}
|
|
860
891
|
className={cn(baseClasses, parentClasses, activeClasses)}
|
|
861
892
|
>
|
|
862
893
|
{content}
|
|
@@ -870,6 +901,7 @@ export default function NestedSideBar() {
|
|
|
870
901
|
key={`item-${itemKey || index}`}
|
|
871
902
|
title={item.title}
|
|
872
903
|
onClick={() => handleDrillDown(item, itemKey)}
|
|
904
|
+
data-active={isActive ? 'true' : undefined}
|
|
873
905
|
className={cn(baseClasses, parentClasses, activeClasses)}
|
|
874
906
|
>
|
|
875
907
|
{content}
|
|
@@ -1040,6 +1072,7 @@ export default function NestedSideBar() {
|
|
|
1040
1072
|
{/* Navigation Content */}
|
|
1041
1073
|
<nav
|
|
1042
1074
|
key={animationKey}
|
|
1075
|
+
ref={navRef}
|
|
1043
1076
|
className={cn('min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-4 px-2', getAnimationClass())}
|
|
1044
1077
|
style={{
|
|
1045
1078
|
scrollbarWidth: 'thin',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createSectionCollapsePreferences, type SectionCollapsePreferences } from './utils';
|
|
2
2
|
|
|
3
3
|
// ============================================
|
|
4
4
|
// Local Storage Persistence
|
|
@@ -7,6 +7,7 @@ import type { SectionCollapsePreferences } from './utils';
|
|
|
7
7
|
const STORAGE_KEY = 'eventcatalog-sidebar-nav';
|
|
8
8
|
const SECTION_PREFERENCES_KEY = 'eventcatalog-sidebar-sections:v2';
|
|
9
9
|
const FAVORITES_KEY = 'eventcatalog-sidebar-favorites';
|
|
10
|
+
const SCROLL_KEY = 'eventcatalog-sidebar-scroll';
|
|
10
11
|
|
|
11
12
|
// ============================================
|
|
12
13
|
// Types
|
|
@@ -47,13 +48,59 @@ export const loadState = (): PersistedState | null => {
|
|
|
47
48
|
}
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
// ============================================
|
|
52
|
+
// Scroll position
|
|
53
|
+
// ============================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The sidebar remounts on every full page load, which resets its scroll offset to the
|
|
57
|
+
* top. Persist offsets (per tab) so the list looks stationary when you follow a link near
|
|
58
|
+
* the bottom. A capped map keyed by drill-down path, so scrolling one level doesn't lose
|
|
59
|
+
* another level's offset (root -> drill in -> scroll -> back keeps the root position).
|
|
60
|
+
*/
|
|
61
|
+
const MAX_SCROLL_ENTRIES = 30;
|
|
62
|
+
|
|
63
|
+
export const saveScrollPosition = (pathKey: string, scrollTop: number): void => {
|
|
64
|
+
try {
|
|
65
|
+
const stored = sessionStorage.getItem(SCROLL_KEY);
|
|
66
|
+
const parsed = stored ? JSON.parse(stored) : {};
|
|
67
|
+
const offsets: Record<string, number> = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
68
|
+
// Re-insert so the map stays ordered by recency, then drop the oldest beyond the cap.
|
|
69
|
+
delete offsets[pathKey];
|
|
70
|
+
offsets[pathKey] = scrollTop;
|
|
71
|
+
const keys = Object.keys(offsets);
|
|
72
|
+
for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_SCROLL_ENTRIES))) {
|
|
73
|
+
delete offsets[stale];
|
|
74
|
+
}
|
|
75
|
+
sessionStorage.setItem(SCROLL_KEY, JSON.stringify(offsets));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.warn('Failed to save sidebar scroll position:', e);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const loadScrollPosition = (pathKey: string): number | null => {
|
|
82
|
+
try {
|
|
83
|
+
const stored = sessionStorage.getItem(SCROLL_KEY);
|
|
84
|
+
if (!stored) return null;
|
|
85
|
+
const offsets = JSON.parse(stored);
|
|
86
|
+
const scrollTop = offsets && typeof offsets === 'object' ? offsets[pathKey] : undefined;
|
|
87
|
+
return typeof scrollTop === 'number' ? scrollTop : null;
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.warn('Failed to load sidebar scroll position:', e);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
50
94
|
// ============================================
|
|
51
95
|
// Collapsed Sections
|
|
52
96
|
// ============================================
|
|
53
97
|
|
|
54
98
|
export const saveCollapsedSections = (preferences: SectionCollapsePreferences): void => {
|
|
55
99
|
try {
|
|
56
|
-
localStorage.setItem(
|
|
100
|
+
localStorage.setItem(
|
|
101
|
+
SECTION_PREFERENCES_KEY,
|
|
102
|
+
JSON.stringify({ expanded: [...preferences.expanded], collapsed: [...preferences.collapsed] })
|
|
103
|
+
);
|
|
57
104
|
} catch (e) {
|
|
58
105
|
console.warn('Failed to save collapsed sections:', e);
|
|
59
106
|
}
|
|
@@ -64,15 +111,16 @@ export const loadCollapsedSections = (): SectionCollapsePreferences => {
|
|
|
64
111
|
const stored = localStorage.getItem(SECTION_PREFERENCES_KEY);
|
|
65
112
|
if (stored) {
|
|
66
113
|
const preferences = JSON.parse(stored);
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
|
|
114
|
+
return createSectionCollapsePreferences(
|
|
115
|
+
Array.isArray(preferences.expanded) ? preferences.expanded : [],
|
|
116
|
+
Array.isArray(preferences.collapsed) ? preferences.collapsed : []
|
|
117
|
+
);
|
|
70
118
|
}
|
|
71
119
|
|
|
72
|
-
return
|
|
120
|
+
return createSectionCollapsePreferences();
|
|
73
121
|
} catch (e) {
|
|
74
122
|
console.warn('Failed to load collapsed sections:', e);
|
|
75
|
-
return
|
|
123
|
+
return createSectionCollapsePreferences();
|
|
76
124
|
}
|
|
77
125
|
};
|
|
78
126
|
|
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
canCollapseGroup,
|
|
4
|
+
createSectionCollapsePreferences,
|
|
5
|
+
findNodeKeyByUrl,
|
|
6
|
+
getDefaultCollapsedState,
|
|
7
|
+
isGroupCollapsed,
|
|
8
|
+
toggleGroupCollapsed,
|
|
9
|
+
} from './utils';
|
|
3
10
|
|
|
4
|
-
const preferences = (expanded: string[] = []) => (
|
|
5
|
-
expanded: new Set(expanded),
|
|
6
|
-
});
|
|
11
|
+
const preferences = (expanded: string[] = [], collapsed: string[] = []) => createSectionCollapsePreferences(expanded, collapsed);
|
|
7
12
|
|
|
8
13
|
describe('sidebar group presentation', () => {
|
|
9
|
-
it('
|
|
10
|
-
expect(
|
|
14
|
+
it('keeps root-level groups expanded', () => {
|
|
15
|
+
expect(canCollapseGroup(true)).toBe(false);
|
|
11
16
|
});
|
|
12
17
|
|
|
13
|
-
it
|
|
14
|
-
expect(
|
|
18
|
+
it('lets every group inside a resource sidebar collapse, whatever its size', () => {
|
|
19
|
+
expect(canCollapseGroup(false)).toBe(true);
|
|
20
|
+
expect(canCollapseGroup(false)).toBe(true);
|
|
15
21
|
});
|
|
16
22
|
|
|
17
|
-
it('keeps
|
|
18
|
-
expect(canCollapseGroup(
|
|
23
|
+
it('keeps groups marked as non-collapsible expanded regardless of their size', () => {
|
|
24
|
+
expect(canCollapseGroup(false, false)).toBe(false);
|
|
19
25
|
});
|
|
20
26
|
|
|
21
|
-
it('
|
|
22
|
-
expect(
|
|
23
|
-
expect(
|
|
27
|
+
it('starts long lists collapsed and short ones open unless told otherwise', () => {
|
|
28
|
+
expect(getDefaultCollapsedState(5)).toBe(false);
|
|
29
|
+
expect(getDefaultCollapsedState(6)).toBe(true);
|
|
30
|
+
expect(getDefaultCollapsedState(6, false)).toBe(false);
|
|
31
|
+
expect(getDefaultCollapsedState(2, true)).toBe(true);
|
|
24
32
|
});
|
|
25
33
|
|
|
26
|
-
it('
|
|
27
|
-
expect(canCollapseGroup(
|
|
34
|
+
it('always allows collapsing when an explicit collapsed state is set, overriding every other rule', () => {
|
|
35
|
+
expect(canCollapseGroup(false, true, true)).toBe(true);
|
|
36
|
+
expect(canCollapseGroup(false, true, false)).toBe(true);
|
|
37
|
+
expect(canCollapseGroup(true, false, true)).toBe(true);
|
|
28
38
|
});
|
|
29
39
|
});
|
|
30
40
|
|
|
@@ -40,6 +50,40 @@ describe('isGroupCollapsed', () => {
|
|
|
40
50
|
it('uses an explicit expanded preference', () => {
|
|
41
51
|
expect(isGroupCollapsed(true, 'outbound-messages', preferences(['outbound-messages']))).toBe(false);
|
|
42
52
|
});
|
|
53
|
+
|
|
54
|
+
it('starts expanded when the group defaults to expanded, until the user collapses it', () => {
|
|
55
|
+
expect(isGroupCollapsed(true, 'owners', preferences(), false)).toBe(false);
|
|
56
|
+
expect(isGroupCollapsed(true, 'owners', preferences([], ['owners']), false)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('toggleGroupCollapsed', () => {
|
|
61
|
+
it('records the user overriding a collapsed-by-default group, then clears it on the way back', () => {
|
|
62
|
+
const opened = toggleGroupCollapsed('g', preferences());
|
|
63
|
+
expect([...opened.expanded]).toEqual(['g']);
|
|
64
|
+
expect(opened.collapsed.size).toBe(0);
|
|
65
|
+
|
|
66
|
+
const closedAgain = toggleGroupCollapsed('g', opened);
|
|
67
|
+
expect(closedAgain.expanded.size).toBe(0);
|
|
68
|
+
expect([...closedAgain.collapsed]).toEqual(['g']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('records the user overriding an expanded-by-default group', () => {
|
|
72
|
+
const closed = toggleGroupCollapsed('g', preferences(), false);
|
|
73
|
+
expect([...closed.collapsed]).toEqual(['g']);
|
|
74
|
+
expect(isGroupCollapsed(true, 'g', closed, false)).toBe(true);
|
|
75
|
+
|
|
76
|
+
const reopened = toggleGroupCollapsed('g', closed, false);
|
|
77
|
+
expect(reopened.collapsed.size).toBe(0);
|
|
78
|
+
expect([...reopened.expanded]).toEqual(['g']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not mutate the previous preferences', () => {
|
|
82
|
+
const before = preferences();
|
|
83
|
+
toggleGroupCollapsed('g', before);
|
|
84
|
+
expect(before.expanded.size).toBe(0);
|
|
85
|
+
expect(before.collapsed.size).toBe(0);
|
|
86
|
+
});
|
|
43
87
|
});
|
|
44
88
|
|
|
45
89
|
describe('findNodeKeyByUrl', () => {
|