@eventcatalog/core 4.8.4 → 4.10.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.
- 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-EGWBSVND.js → chunk-5XURY2JV.js} +1 -1
- package/dist/{chunk-FQEK3FUI.js → chunk-BDFTJVGN.js} +1 -1
- package/dist/{chunk-QB4W7ZUL.js → chunk-BQXEWQ5J.js} +1 -1
- package/dist/{chunk-Y57SL3H3.js → chunk-HFAQXAG3.js} +1 -1
- package/dist/{chunk-AATZMWL3.js → chunk-IC6XSXJO.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/Badge.astro +3 -1
- package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +0 -5
- 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/SchemaViewer/schema-viewer-utils.spec.ts +3 -3
- package/eventcatalog/src/components/MDX/SchemaViewer/schema-viewer-utils.ts +2 -2
- package/eventcatalog/src/components/SchemaExplorer/useDarkMode.ts +13 -4
- 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/components/Tables/columns/TeamsTableColumns.tsx +1 -1
- package/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx +2 -2
- package/eventcatalog/src/content.config.ts +71 -1
- package/eventcatalog/src/enterprise/api/schemas/[collection]/[id]/[version]/index.ts +3 -3
- package/eventcatalog/src/enterprise/collections/resource-docs-utils.ts +19 -0
- package/eventcatalog/src/enterprise/fields/pages/fields.astro +2 -0
- package/eventcatalog/src/enterprise/tools/catalog-tools.ts +1 -1
- 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 +4 -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/llm/schemas.txt.ts +3 -3
- 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/schemas/[type]/[id]/[version]/_index.data.ts +6 -4
- package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +6 -2
- package/eventcatalog/src/pages/schemas/explorer/_index.data.ts +2 -2
- 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/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 +213 -122
- 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/stores/theme-store.ts +6 -3
- package/eventcatalog/src/styles/tailwind.css +82 -0
- package/eventcatalog/src/utils/collections/resource-docs.ts +1 -0
- package/eventcatalog/src/utils/collections/schema-loader.ts +10 -8
- package/package.json +3 -2
|
@@ -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', () => {
|
|
@@ -1,20 +1,66 @@
|
|
|
1
1
|
// Shared utilities for NestedSideBar components
|
|
2
2
|
|
|
3
3
|
export const SIDEBAR_GROUP_COLLAPSE_THRESHOLD = 5;
|
|
4
|
-
const GROUP_TITLES_WITHOUT_COUNT = new Set(['Quick Reference', 'Architecture', 'Resources']);
|
|
5
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The user's overrides of each group's default collapse state, keyed by group id.
|
|
7
|
+
* A group is in at most one set; absence means "use the group's default".
|
|
8
|
+
*/
|
|
6
9
|
export type SectionCollapsePreferences = {
|
|
7
10
|
expanded: Set<string>;
|
|
11
|
+
collapsed: Set<string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const createSectionCollapsePreferences = (
|
|
15
|
+
expanded: Iterable<string> = [],
|
|
16
|
+
collapsed: Iterable<string> = []
|
|
17
|
+
): SectionCollapsePreferences => ({
|
|
18
|
+
expanded: new Set(expanded),
|
|
19
|
+
collapsed: new Set(collapsed),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether a group shows a caret and can be toggled. Every group inside a resource's sidebar
|
|
24
|
+
* can collapse (docs-sidebar behaviour); the root catalog level stays open. An explicit
|
|
25
|
+
* `collapsed` (set via sidebar.json) overrides every other rule, including `collapsible: false`.
|
|
26
|
+
*/
|
|
27
|
+
export const canCollapseGroup = (isTopLevel: boolean, collapsible = true, collapsed?: boolean): boolean => {
|
|
28
|
+
if (collapsed !== undefined) return true;
|
|
29
|
+
return collapsible && !isTopLevel;
|
|
8
30
|
};
|
|
9
31
|
|
|
10
|
-
|
|
11
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The state a group starts in before the user touches it: an explicit `collapsed` wins,
|
|
34
|
+
* otherwise long lists start collapsed and short ones start open.
|
|
35
|
+
*/
|
|
36
|
+
export const getDefaultCollapsedState = (childCount: number, collapsed?: boolean): boolean =>
|
|
37
|
+
collapsed ?? childCount > SIDEBAR_GROUP_COLLAPSE_THRESHOLD;
|
|
12
38
|
|
|
13
|
-
export const
|
|
14
|
-
|
|
39
|
+
export const isGroupCollapsed = (
|
|
40
|
+
canCollapse: boolean,
|
|
41
|
+
groupId: string,
|
|
42
|
+
preferences: SectionCollapsePreferences,
|
|
43
|
+
defaultCollapsed = true
|
|
44
|
+
): boolean => {
|
|
45
|
+
if (!canCollapse) return false;
|
|
46
|
+
if (preferences.expanded.has(groupId)) return false;
|
|
47
|
+
if (preferences.collapsed.has(groupId)) return true;
|
|
48
|
+
return defaultCollapsed;
|
|
49
|
+
};
|
|
15
50
|
|
|
16
|
-
|
|
17
|
-
|
|
51
|
+
/** Flip a group's state, recording it as an override of its default. */
|
|
52
|
+
export const toggleGroupCollapsed = (
|
|
53
|
+
groupId: string,
|
|
54
|
+
preferences: SectionCollapsePreferences,
|
|
55
|
+
defaultCollapsed = true
|
|
56
|
+
): SectionCollapsePreferences => {
|
|
57
|
+
const next = createSectionCollapsePreferences(preferences.expanded, preferences.collapsed);
|
|
58
|
+
const isCurrentlyCollapsed = isGroupCollapsed(true, groupId, preferences, defaultCollapsed);
|
|
59
|
+
next.expanded.delete(groupId);
|
|
60
|
+
next.collapsed.delete(groupId);
|
|
61
|
+
if (isCurrentlyCollapsed) next.expanded.add(groupId);
|
|
62
|
+
else next.collapsed.add(groupId);
|
|
63
|
+
return next;
|
|
18
64
|
};
|
|
19
65
|
|
|
20
66
|
/**
|
|
@@ -82,7 +82,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [
|
|
|
82
82
|
filterFn: filterByName,
|
|
83
83
|
}),
|
|
84
84
|
|
|
85
|
-
columnHelper.accessor(
|
|
85
|
+
columnHelper.accessor((row) => row.data.source, {
|
|
86
86
|
id: 'source',
|
|
87
87
|
header: () => <span>{tableConfiguration.columns?.source?.label || 'Source'}</span>,
|
|
88
88
|
cell: (info) => <DirectorySourceCell source={info.getValue() as { provider: string; url?: string } | undefined} />,
|
|
@@ -85,7 +85,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [
|
|
|
85
85
|
filterFn: filterByName,
|
|
86
86
|
}),
|
|
87
87
|
|
|
88
|
-
columnHelper.accessor(
|
|
88
|
+
columnHelper.accessor((row) => row.data.source, {
|
|
89
89
|
id: 'source',
|
|
90
90
|
header: () => <span>{tableConfiguration.columns?.source?.label || 'Source'}</span>,
|
|
91
91
|
cell: (info) => <DirectorySourceCell source={info.getValue() as { provider: string; url?: string } | undefined} />,
|
|
@@ -95,7 +95,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [
|
|
|
95
95
|
},
|
|
96
96
|
}),
|
|
97
97
|
|
|
98
|
-
columnHelper.accessor(
|
|
98
|
+
columnHelper.accessor((row) => row.data.role, {
|
|
99
99
|
id: 'role',
|
|
100
100
|
header: () => <span>{tableConfiguration.columns?.role?.label || 'Role'}</span>,
|
|
101
101
|
cell: (info) => {
|