Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/treeview #1132

Merged
merged 19 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { isCollectionVisibleInCatalog } from '@eventcatalog';
import { buildUrl } from '@utils/url-builder';
import { getChannels } from '@utils/channels';
import { getDomains } from '@utils/collections/domains';
import { getFlows } from '@utils/collections/flows';
import { getServices } from '@utils/collections/services';
import { getCommands } from '@utils/commands';
import { getEvents } from '@utils/events';
import { getQueries } from '@utils/queries';
import { getTeams } from '@utils/teams';
import { getUsers } from '@utils/users';

export async function getCatalogResources({ currentPath }: { currentPath: string }) {
const events = await getEvents({ getAllVersions: false });
const commands = await getCommands({ getAllVersions: false });
const queries = await getQueries({ getAllVersions: false });
const services = await getServices({ getAllVersions: false });
const domains = await getDomains({ getAllVersions: false });
const channels = await getChannels({ getAllVersions: false });
const flows = await getFlows({ getAllVersions: false });
const teams = await getTeams();
const users = await getUsers();

const messages = [...events, ...commands, ...queries];

// @ts-ignore for large catalogs https://github.com/event-catalog/eventcatalog/issues/552
const allData = [...domains, ...services, ...messages, ...channels, ...flows, ...teams, ...users];

const allDataAsSideNav = allData.reduce((acc, item) => {
const title = item.collection;
const group = acc[title] || [];
const route = currentPath.includes('visualiser') ? 'visualiser' : 'docs';

if (
currentPath.includes('visualiser') &&
(item.collection === 'teams' || item.collection === 'users' || item.collection === 'channels')
) {
return acc;
}

const navigationItem = {
label: item.data.name,
version: item.collection === 'teams' || item.collection === 'users' ? null : item.data.version,
// items: item.collection === 'users' ? [] : item.headings,
visible: isCollectionVisibleInCatalog(item.collection),
// @ts-ignore
href: item.data.version
? // @ts-ignore
buildUrl(`/${route}/${item.collection}/${item.data.id}/${item.data.version}`)
: buildUrl(`/${route}/${item.collection}/${item.data.id}`),
collection: item.collection,
};

group.push(navigationItem);

return {
...acc,
[title]: group,
};
}, {} as any);

const sideNav = {
...(currentPath.includes('visualiser')
? {
'bounded context map': [
{ label: 'Domain map', href: buildUrl('/visualiser/context-map'), collection: 'bounded-context-map' },
],
}
: {}),
...allDataAsSideNav,
};

return sideNav;
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const CatalogResourcesSideBar: React.FC<CatalogResourcesSideBarProps> = ({ resou
if (!isInitialized) return null;

return (
<nav className="space-y-6 text-black ">
<nav className="space-y-6 text-black px-5 py-4 ">
<div className="space-y-2">
<div className="mb-4 px-1">
<input
Expand Down
24 changes: 24 additions & 0 deletions eventcatalog/src/components/SideNav/SideNav.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
import type { HTMLAttributes } from 'astro/types';
import EcConfig from '@config';
// FlatView
import CatalogResourcesSideBar from './CatalogResourcesSideBar';
import { getCatalogResources } from './CatalogResourcesSideBar/getCatalogResources';
// TreeView
import { SideNavTreeView } from './TreeView';
import { getTreeView } from './TreeView/getTreeView';

interface Props extends Omit<HTMLAttributes<'div'>, 'children'> {}

const currentPath = Astro.url.pathname;

let props;
const SIDENAV_TYPE = EcConfig?.docs?.sidebar?.type ?? 'FLAT_VIEW';
if (SIDENAV_TYPE === 'FLAT_VIEW') props = await getCatalogResources({ currentPath });
else if (SIDENAV_TYPE === 'TREE_VIEW') props = getTreeView({ projectDir: process.env.PROJECT_DIR!, currentPath });
---

<div {...Astro.props}>
{SIDENAV_TYPE === 'FLAT_VIEW' && <CatalogResourcesSideBar resources={props} currentPath={currentPath} client:load />}
{SIDENAV_TYPE === 'TREE_VIEW' && <SideNavTreeView client:only transition:persist tree={props} />}
</div>
148 changes: 148 additions & 0 deletions eventcatalog/src/components/SideNav/TreeView/getTreeView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import fs from 'fs';
import path from 'path';
import gm from 'gray-matter';
import { globSync } from 'glob';
import type { CollectionKey } from 'astro:content';
import { buildUrl } from '@utils/url-builder';

export type TreeNode = {
id: string;
name: string;
version: string;
href?: string;
type: CollectionKey | null;
children: TreeNode[];
};

/**
* Resource types that should be in the sidenav
*/
const RESOURCE_TYPES = ['domains', 'services', 'events', 'commands', 'queries', 'flows', 'teams', 'users', 'channels'];
// const RESOURCE_TYPES = ['domains', 'services', 'events', 'commands', 'queries', 'flows', 'channels'];

/**
* Check if the path has a RESOURCE_TYPE on path
*/
function canBeResource(dirPath: string) {
const parts = dirPath.split(path.sep);
for (let i = parts.length - 1; i >= 0; i--) {
if (RESOURCE_TYPES.includes(parts[i])) return true;
}
return false;
}

function isNotVersioned(dirPath: string) {
const parts = dirPath.split(path.sep);
return parts.every((p) => p !== 'versioned');
}

function getResourceType(filePath: string): CollectionKey | null {
const parts = filePath.split(path.sep);
for (let i = parts.length - 1; i >= 0; i--) {
if (RESOURCE_TYPES.includes(parts[i])) return parts[i] as CollectionKey;
}
return null;
}

function traverse(
directory: string,
parentNode: TreeNode,
options: { ignore?: CollectionKey[]; basePathname: 'docs' | 'visualiser' }
) {
let node: TreeNode | null = null;

const resourceType = getResourceType(directory);

const markdownFiles = globSync(path.join(directory, '/*.md'));
const isResourceIgnored = options?.ignore && resourceType && options.ignore.includes(resourceType);

if (markdownFiles.length > 0 && !isResourceIgnored) {
const resourceFilePath = markdownFiles.find((md) => md.endsWith('index.md'));

if (resourceType === 'teams' || resourceType === 'users') {
// Teams and Users aren't nested. Just append to the parentNode.
markdownFiles.forEach((md) => {
const resourceDef = gm.read(md);
parentNode.children.push({
id: resourceDef.data.id,
name: resourceDef.data.name,
type: resourceType,
version: resourceDef.data.version,
children: [],
href: encodeURI(buildUrl(`/${options.basePathname}/${resourceType}/${resourceDef.data.id}`)),
});
});
} else if (resourceFilePath) {
const resourceDef = gm.read(resourceFilePath);
node = {
id: resourceDef.data.id,
name: resourceDef.data.name,
type: resourceType,
version: resourceDef.data.version,
href: encodeURI(buildUrl(`/${options.basePathname}/${resourceType}/${resourceDef.data.id}/${resourceDef.data.version}`)),
children: [],
};
parentNode.children.push(node);
}
}

const directories = fs.readdirSync(directory).filter((name) => {
const dirPath = path.join(directory, name);
return fs.statSync(dirPath).isDirectory() && isNotVersioned(dirPath) && canBeResource(dirPath);
});
for (const dir of directories) {
traverse(path.join(directory, dir), node || parentNode, options);
}
}

function groupByType(parentNode: TreeNode) {
const next = parentNode.children;
const siblingTypes = new Set(parentNode.children.map((n) => n.type));
const shouldGroup = parentNode.type === 'services' || siblingTypes.size > 1;

if (shouldGroup) {
const acc: Record<string, TreeNode[]> = {};
parentNode.children.forEach((n) => {
if (!n.type) return; // TODO: Just ignore or remove the type null???
if (!(n.type in acc)) acc[n.type] = [];
acc[n.type].push(n);
});
parentNode.children = Object.entries(acc).map(([type, nodes]) => ({
id: `${parentNode.id}/${type}`,
name: type,
type: type as CollectionKey,
version: '0',
children: nodes,
isLabel: true,
}));
}

// Go to next level
next.forEach((n) => {
if (n?.children.length === 0) return; // Leaf node
groupByType(n);
});
}

export function getTreeView({ projectDir, currentPath }: { projectDir: string; currentPath: string }): TreeNode {
const basePathname = currentPath.split('/')[1] as 'docs' | 'visualiser';
const rootNode: TreeNode = {
id: '/',
name: 'root',
type: null,
version: '0',
children: [],
};
traverse(projectDir, rootNode, {
basePathname,
ignore: basePathname === 'visualiser' ? ['teams', 'users', 'channels'] : undefined,
});
groupByType(rootNode);

// order the children by domains, services, events, commands, queries, flows, teams, users, channels
rootNode.children.sort((a, b) => {
return RESOURCE_TYPES.indexOf(a.type || '') - RESOURCE_TYPES.indexOf(b.type || '');
});

return rootNode;
}
103 changes: 103 additions & 0 deletions eventcatalog/src/components/SideNav/TreeView/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { purple, gray } from 'tailwindcss/colors';
import { TreeView } from '@primer/react';
import { FeatureFlags } from '@primer/react/experimental';
import { navigate } from 'astro:transitions/client';
import type { TreeNode as RawTreeNode } from './getTreeView';
import { getIconForCollection } from '@utils/collections/icons';
import { useEffect, useState } from 'react';
import './styles.css';
type TreeNode = RawTreeNode & { isLabel?: true; isDefaultExpanded?: boolean };

function isCurrentNode(node: TreeNode, currentPathname: string) {
return currentPathname === node.href;
}

function TreeNode({ node }: { node: TreeNode }) {
const Icon = getIconForCollection(node.type ?? '');
const [isCurrent, setIsCurrent] = useState(document.location.pathname === node.href);

useEffect(() => {
const abortCtrl = new AbortController();
// prettier-ignore
document.addEventListener(
'astro:page-load',
() => setIsCurrent(document.location.pathname === node.href),
{ signal: abortCtrl.signal },
);
return () => abortCtrl.abort();
}, [document, node]);

return (
<TreeView.Item
key={node.id}
id={node.id}
current={isCurrent}
defaultExpanded={node?.isDefaultExpanded}
onSelect={node?.isLabel || !node?.href ? undefined : () => navigate(node.href!)}
>
{!node?.isLabel && (
<TreeView.LeadingVisual>
<Icon className="w-3 -ml-1" />
</TreeView.LeadingVisual>
)}
<span className={node?.isLabel ? ' capitalize text-gray-700 text-[14px]' : 'font-thin text-[14px] -ml-0.5'}>
{node.name} {node.isLabel ? `(${node.children.length})` : ''}
</span>
{(node.children || []).length > 0 && (
<TreeView.SubTree>
{node.children!.map((childNode) => (
<TreeNode key={childNode.id} node={childNode} />
))}
</TreeView.SubTree>
)}
</TreeView.Item>
);
}

const DEFAULT_EXPANDED_TYPES = ['domains', 'services', 'channels'];

export function SideNavTreeView({ tree }: { tree: TreeNode }) {
function bubbleUpExpanded(parentNode: TreeNode) {
if (isCurrentNode(parentNode, document.location.pathname)) return true;
// if (DEFAULT_EXPANDED_TYPES.includes(parentNode.type || '')) {
// return parentNode.isDefaultExpanded = true;
// };
return (parentNode.isDefaultExpanded = parentNode.children.some(bubbleUpExpanded));
}
bubbleUpExpanded(tree);

return (
// NOTE: Enable the `primer_react_css_modules_ga` was needed to keep the
// styles between astro page transitions. Otherwise `TreeView` lose the styles.
<FeatureFlags flags={{ primer_react_css_modules_ga: true }}>
<nav id="resources-tree" className="px-2 py-2">
<TreeView
truncate={false}
style={{
// @ts-expect-error inline css var
// NOTE: CSS vars extracted from https://github.com/primer/react/blob/%40primer/react%4037.11.2/packages/react/src/TreeView/TreeView.module.css
'--base-size-8': '0.5rem',
'--base-size-12': '0.75rem',
'--borderColor-muted': '#fff',
'--borderRadius-medium': '0.375rem',
'--borderWidth-thick': '0.125rem',
'--borderWidth-thin': '0.0625rem',
'--boxShadow-thick': 'inset 0 0 0 var(--borderWidth-thick)',
'--control-transparent-bgColor-hover': '#656c7626',
'--control-transparent-bgColor-selected': '#656c761a',
// '--fgColor-accent': purple[700],
'--fgColor-default': gray[600],
'--fgColor-muted': gray[600],
'--text-body-size-medium': '0.875rem',
'--stack-gap-condensed': '0.5rem',
'--treeViewItem-leadingVisual-iconColor-rest': 'var(--fgColor-muted)',
}}
>
{tree.children.map((n) => (
<TreeNode key={n.id} node={n} />
))}
</TreeView>
</nav>
</FeatureFlags>
);
}
3 changes: 3 additions & 0 deletions eventcatalog/src/components/SideNav/TreeView/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.PRIVATE_TreeView-item-level-line {
display: none;
}
Loading
Loading