diff --git a/calm-hub-ui/AGENTS.md b/calm-hub-ui/AGENTS.md index 57db0256e..217e176b7 100644 --- a/calm-hub-ui/AGENTS.md +++ b/calm-hub-ui/AGENTS.md @@ -280,7 +280,9 @@ restyled desktop while targeting mobile — don't. menu) portal into the navbar `#navbar-actions` slot instead of floating in the render pane. See `components/navbar/Navbar.tsx` and `diagram-section/DiagramSection.tsx`. - **iOS-style drill-down explorer** on mobile (`tree-navigation/MobileNavMenu.tsx`): - one flat list per level, not a tree. + one flat list per level for `types`, `resources`, `domains` and `controls`. The + `namespaces` level is the exception — it renders as a nested tree, because the + hierarchy is already latent in the dotted names. - **Full-bleed render pane** with the minimap/zoom controls hidden (pinch is the native gesture) — see `visualizer/components/reactflow/`. - **Tabbed detail views** where desktop stacks panels (e.g. diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index 3d68def4e..89b56aec2 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useMatch, useNavigate } from 'react-router-dom'; -import { IoChevronForwardOutline, IoCompassOutline } from 'react-icons/io5'; +import { IoCompassOutline } from 'react-icons/io5'; import { ExploreRail } from './components/explore-rail/ExploreRail.js'; +import { CollapsedRail } from './components/explore-rail/CollapsedRail.js'; import { MobileNavMenu } from './components/tree-navigation/MobileNavMenu.js'; import { NamespacePage } from './components/namespace-page/NamespacePage.js'; import { DomainPage } from './components/domain-page/DomainPage.js'; @@ -421,7 +422,7 @@ export default function Hub() {
{/* Desktop: inline, collapsible browse rail. */} {!isMobile && ( -
+
{isSidebarOpen ? ( setIsSidebarOpen(false)} /> ) : ( -
-
- -
-
+ setIsSidebarOpen(true)} /> )}
)} diff --git a/calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.test.tsx b/calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.test.tsx new file mode 100644 index 000000000..9249fe83d --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { CollapsedRail } from './CollapsedRail.js'; +import { colors } from '../../../theme/colors.js'; +import { redesignTokens } from '../../../theme/redesign-tokens.js'; +import type { NamespaceCounts } from '../../../model/counts.js'; + +function nc(namespace: string, total: number): NamespaceCounts { + return { namespace, architectures: 0, patterns: 0, flows: 0, standards: 0, adrs: 0, interfaces: 0, total }; +} + +const namespaceCounts: NamespaceCounts[] = [nc('finos.calm', 5), nc('barclays.payments', 3), nc('acme', 2)]; + +const renderRail = (path = '/') => { + const onExpand = vi.fn(); + const rail = ; + const utils = render( + + + {['/', '/namespace/:ns', '/:namespace/:type/:id/:version'].map((p) => ( + + ))} + + + ); + return { ...utils, onExpand }; +}; + +describe('CollapsedRail', () => { + it('shows one initial per root namespace, in tree order', () => { + renderRail(); + const initials = screen.getAllByRole('button', { name: /^(acme|barclays|finos)$/ }); + expect(initials.map((b) => b.textContent)).toEqual(['A', 'B', 'F']); + }); + + it('calls onExpand from the expand-sidebar button', () => { + const { onExpand } = renderRail(); + fireEvent.click(screen.getByLabelText('Expand sidebar')); + expect(onExpand).toHaveBeenCalled(); + }); + + it('accents the root initial whose subtree contains the active namespace', () => { + renderRail('/namespace/finos.calm'); + const finosInitial = screen.getByRole('button', { name: 'finos' }); + expect(finosInitial).toHaveStyle({ backgroundColor: colors.redesign.tintBg, boxShadow: redesignTokens.shadow.railAccent }); + + const acmeInitial = screen.getByRole('button', { name: 'acme' }); + expect(acmeInitial).not.toHaveStyle({ backgroundColor: colors.redesign.tintBg }); + }); + + it('opens the fly-out for a root on hover', () => { + renderRail(); + expect(screen.queryByText('calm')).not.toBeInTheDocument(); + + fireEvent.mouseEnter(screen.getByRole('button', { name: 'finos' }).parentElement!); + expect(screen.getByText('calm')).toBeInTheDocument(); + + fireEvent.mouseLeave(screen.getByRole('button', { name: 'finos' }).parentElement!); + expect(screen.queryByText('calm')).not.toBeInTheDocument(); + }); + + it('opens the fly-out for a root on focus', () => { + renderRail(); + const finosInitial = screen.getByRole('button', { name: 'finos' }); + expect(screen.queryByText('calm')).not.toBeInTheDocument(); + + fireEvent.focus(finosInitial); + expect(screen.getByText('calm')).toBeInTheDocument(); + }); + + it('closes the fly-out on Escape', () => { + renderRail(); + const finosInitial = screen.getByRole('button', { name: 'finos' }); + fireEvent.focus(finosInitial); + expect(screen.getByText('calm')).toBeInTheDocument(); + + fireEvent.keyDown(finosInitial, { key: 'Escape' }); + expect(screen.queryByText('calm')).not.toBeInTheDocument(); + }); + + it('renders a group-only root row inside the fly-out without a link', () => { + renderRail(); + fireEvent.focus(screen.getByRole('button', { name: 'barclays' })); + + // 'barclays' itself has no direct count (only barclays.payments does) — + // its own row inside the fly-out is text, not a link. + expect(screen.getByText('barclays', { selector: 'span' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /^barclays$/ })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /payments/ })).toHaveAttribute('href', '/namespace/barclays.payments'); + }); +}); diff --git a/calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.tsx b/calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.tsx new file mode 100644 index 000000000..75f900985 --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.tsx @@ -0,0 +1,153 @@ +import { useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { IoChevronForwardOutline } from 'react-icons/io5'; +import { NamespaceCounts } from '../../../model/counts.js'; +import { colors } from '../../../theme/colors.js'; +import { redesignTokens } from '../../../theme/redesign-tokens.js'; +import { CountBadge } from './CountBadge.js'; +import { buildNamespaceTree, flattenNamespaceTree, type NamespaceTreeNode } from './namespace-tree.js'; + +interface CollapsedRailProps { + namespaceCounts: NamespaceCounts[]; + onExpand: () => void; +} + +function isWithin(container: HTMLElement, target: EventTarget | null): boolean { + return target instanceof Node && container.contains(target); +} + +function FlyoutRow({ node, depth, active }: { node: NamespaceTreeNode; depth: number; active: boolean }) { + const isNamespace = node.total !== null; + const style = active ? { color: colors.redesign.activeText } : { color: colors.redesign.bodyAlt }; + + const content = ( + <> + {node.segment} + {node.total !== null && } + + ); + + return ( +
+ {isNamespace ? ( + + {content} + + ) : ( + + {content} + + )} +
+ ); +} + +interface RootInitialProps { + root: NamespaceTreeNode; + isActive: boolean; + isOpen: boolean; + activeNamespace?: string; + onOpen: () => void; + onClose: () => void; +} + +function RootInitial({ root, isActive, isOpen, activeNamespace, onOpen, onClose }: RootInitialProps) { + const rows = useMemo(() => flattenNamespaceTree([root], { collapsed: new Set(), filtering: false, visible: new Set() }), [root]); + + return ( +
{ + if (!isWithin(e.currentTarget, e.relatedTarget)) onClose(); + }} + onKeyDown={(e) => { + if (e.key === 'Escape') onClose(); + }} + > + + + {isOpen && ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+ ); +} + +/** + * The rail collapsed to a 24px strip of per-root initials. Each initial opens a + * fully-flattened fly-out of its subtree on hover or focus — there is no collapse + * state in the fly-out, it is transient. + */ +export function CollapsedRail({ namespaceCounts, onExpand }: CollapsedRailProps) { + // Matches ExploreRail: on the detail route the param is `namespace`, so the accent + // survives a detail session just as the expanded rail's highlight does. + const { ns, namespace } = useParams<{ ns?: string; namespace?: string }>(); + const activeNamespace = ns ?? namespace; + const [openPath, setOpenPath] = useState(null); + const tree = useMemo(() => buildNamespaceTree(namespaceCounts), [namespaceCounts]); + + return ( +
+
+ +
+ +
+ {tree.map((root) => ( + setOpenPath(root.path)} + onClose={() => setOpenPath((prev) => (prev === root.path ? null : prev))} + /> + ))} +
+
+ ); +} diff --git a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx index 85d8a2943..9d2e3e594 100644 --- a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx +++ b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ExploreRail } from './ExploreRail.js'; +import { createMemoryStorage } from '../../../test-support/memory-storage.js'; import type { NamespaceCounts, DomainControlCount } from '../../../model/counts.js'; // Counts are owned by Hub and passed in as props; the rail no longer fetches them. @@ -20,6 +21,8 @@ interface RenderRailOptions { domainsLoading?: boolean; namespacesFailed?: boolean; domainsFailed?: boolean; + namespaceCounts?: NamespaceCounts[]; + storage?: Storage; } const renderRail = (path = '/', opts: RenderRailOptions = {}) => @@ -32,13 +35,14 @@ const renderRail = (path = '/', opts: RenderRailOptions = {}) => path={p} element={ } /> @@ -149,3 +153,76 @@ describe('ExploreRail', () => { expect(screen.queryByRole('link', { name: /finos/ })).not.toBeInTheDocument(); }); }); + +describe('ExploreRail — namespace hierarchy', () => { + // finos has two children (calm, wave) and its own total; traderx is an unrelated flat root. + const nestedNamespaceCounts = [ + { namespace: 'finos', total: 10 }, + { namespace: 'finos.calm', total: 5 }, + { namespace: 'finos.wave', total: 3 }, + { namespace: 'traderx', total: 9 }, + ] as NamespaceCounts[]; + + it('collapsing finos hides its children, shows the +8 ghost count, and leaves traderx visible', async () => { + const storage = createMemoryStorage(); + renderRail('/', { namespaceCounts: nestedNamespaceCounts, storage }); + await screen.findByRole('link', { name: 'finos' }); + expect(screen.getByRole('link', { name: 'finos.calm' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse finos' })); + + expect(screen.queryByRole('link', { name: 'finos.calm' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'finos.wave' })).not.toBeInTheDocument(); + expect(screen.getByTestId('nested-count-badge')).toHaveTextContent('+8'); + expect(screen.getByRole('link', { name: 'traderx' })).toBeInTheDocument(); + }); + + it('filtering "tra" surfaces both finos.traderx and traderx with full names and highlights', async () => { + const deepNamespaceCounts = [ + { namespace: 'finos', total: 4 }, + { namespace: 'finos.traderx', total: 2 }, + { namespace: 'traderx', total: 9 }, + ] as NamespaceCounts[]; + renderRail('/', { namespaceCounts: deepNamespaceCounts }); + await screen.findByRole('link', { name: 'finos' }); + + fireEvent.change(screen.getByLabelText('Filter namespaces'), { target: { value: 'tra' } }); + + expect(screen.getByRole('link', { name: 'finos.traderx' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'traderx' })).toBeInTheDocument(); + expect(screen.getAllByText('tra', { selector: 'mark' }).length).toBeGreaterThan(0); + }); + + it('a filter surfaces a match under an explicitly collapsed ancestor, and clearing it restores the collapse', async () => { + const storage = createMemoryStorage(); + renderRail('/', { namespaceCounts: nestedNamespaceCounts, storage }); + await screen.findByRole('link', { name: 'finos' }); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse finos' })); + expect(screen.queryByRole('link', { name: 'finos.calm' })).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Filter namespaces'), { target: { value: 'calm' } }); + expect(screen.getByRole('link', { name: 'finos.calm' })).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Filter namespaces'), { target: { value: '' } }); + expect(screen.queryByRole('link', { name: 'finos.calm' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand finos' })).toBeInTheDocument(); + }); + + it('deep-linking to /namespace/finos.calm shows the active row even with finos collapsed in storage', async () => { + const storage = createMemoryStorage(); + storage.setItem('calmHub.railCollapsedNamespaces', JSON.stringify(['finos'])); + renderRail('/namespace/finos.calm', { namespaceCounts: nestedNamespaceCounts, storage }); + + const active = await screen.findByRole('link', { name: 'finos.calm' }); + expect(active).toHaveAttribute('aria-current', 'page'); + }); + + it('renders no chevron and no nested badge on CONTROL DOMAINS rows', async () => { + renderRail(); + await screen.findByRole('link', { name: /security/ }); + expect(screen.queryByTestId('nested-count-badge')).not.toBeInTheDocument(); + // Domain rows never gain a disclosure chevron — only namespace rows with children do. + expect(screen.queryByRole('button', { name: /security/ })).not.toBeInTheDocument(); + }); +}); diff --git a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx index 75d029aa1..e8999aa31 100644 --- a/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx +++ b/calm-hub-ui/src/hub/components/explore-rail/ExploreRail.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useMemo, useState } from 'react'; +import { ReactNode, useState } from 'react'; import { useParams } from 'react-router-dom'; import { IoCompassOutline, IoChevronBackOutline } from 'react-icons/io5'; import { NamespaceCounts, DomainControlCount } from '../../../model/counts.js'; @@ -7,6 +7,8 @@ import { redesignTokens } from '../../../theme/redesign-tokens.js'; import { RailItem } from './RailItem.js'; import { RailSectionLabel } from './RailSectionLabel.js'; import { LoadingSpinner } from '../LoadingSpinner.js'; +import { NamespaceRailItem } from './NamespaceRailItem.js'; +import { useNamespaceTree } from './useNamespaceTree.js'; interface ExploreRailProps { /** Per-namespace counts, fetched once by {@link Hub} and passed down. */ @@ -23,6 +25,8 @@ interface ExploreRailProps { domainsFailed?: boolean; /** Collapse the rail (keeps the existing sidebar collapse affordance). */ onCollapse?: () => void; + /** Storage instance for persisting the namespace tree's collapsed set. Defaults to localStorage. Inject a fake in tests. */ + storage?: Storage; } function RailSpinner({ label }: { label: string }) { @@ -57,6 +61,7 @@ export function ExploreRail({ namespacesFailed, domainsFailed, onCollapse, + storage, }: ExploreRailProps) { // `ns` comes from /namespace/:ns; on the detail route /:namespace/:type/:id/:version the // param is `namespace`. Fall back to it so the rail keeps its highlight during a detail session. @@ -66,10 +71,7 @@ export function ExploreRail({ const [filter, setFilter] = useState(''); const needle = filter.trim().toLowerCase(); - const filteredNamespaces = useMemo( - () => namespaceCounts.filter((nc) => nc.namespace.toLowerCase().includes(needle)), - [namespaceCounts, needle] - ); + const { rows, filtering, toggleCollapsed } = useNamespaceTree({ namespaceCounts, needle, activeNamespace, storage }); return (
) : namespacesFailed ? ( Couldn't load namespaces - ) : filteredNamespaces.length === 0 ? ( - - {namespaceCounts.length === 0 ? 'Nothing here' : 'No namespaces match your filter'} - + ) : rows.length === 0 ? ( + {needle === '' ? 'Nothing here' : 'No namespaces match your filter'} ) : ( - filteredNamespaces.map((nc) => ( - ( + )) )} diff --git a/calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.test.tsx b/calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.test.tsx new file mode 100644 index 000000000..ab0f5bcc7 --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.test.tsx @@ -0,0 +1,174 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { NamespaceRailItem } from './NamespaceRailItem.js'; +import { buildNamespaceTree } from './namespace-tree.js'; +import { colors } from '../../../theme/colors.js'; +import type { NamespaceCounts } from '../../../model/counts.js'; + +function nc(namespace: string, total: number): NamespaceCounts { + return { namespace, architectures: 0, patterns: 0, flows: 0, standards: 0, adrs: 0, interfaces: 0, total }; +} + +const renderItem = (overrides: Partial> = {}) => { + const tree = buildNamespaceTree([nc('finos.calm', 5)]); + const node = tree[0].children[0]; // finos.calm — a real namespace at depth 1 + const props: React.ComponentProps = { + node, + depth: 1, + hasChildren: false, + collapsed: false, + descendantTotal: 0, + active: false, + filtering: false, + needle: '', + onToggleCollapsed: vi.fn(), + ...overrides, + }; + return render( + + + + ); +}; + +describe('NamespaceRailItem', () => { + it('renders the chevron and the label as distinct hit targets', () => { + const onToggleCollapsed = vi.fn(); + renderItem({ hasChildren: true, onToggleCollapsed }); + + const chevron = screen.getByRole('button', { name: /finos.calm/ }); + const link = screen.getByRole('link', { name: 'finos.calm' }); + expect(chevron).not.toBe(link); + + fireEvent.click(chevron); + expect(onToggleCollapsed).toHaveBeenCalledWith('finos.calm'); + + // Clicking the label must not toggle collapse — that is the chevron's job alone. + onToggleCollapsed.mockClear(); + fireEvent.click(link); + expect(onToggleCollapsed).not.toHaveBeenCalled(); + }); + + it('flips aria-expanded and its accessible label with collapsed state', () => { + const { rerender } = renderItem({ hasChildren: true, collapsed: false }); + expect(screen.getByRole('button', { name: 'Collapse finos.calm' })).toHaveAttribute('aria-expanded', 'true'); + + rerender( + + + + ); + expect(screen.getByRole('button', { name: 'Expand finos.calm' })).toHaveAttribute('aria-expanded', 'false'); + }); + + it('shows a child row by its last segment, with the full path as title and accessible name', () => { + renderItem(); + const link = screen.getByRole('link', { name: 'finos.calm' }); + expect(link).toHaveTextContent('calm'); + expect(link).not.toHaveTextContent('finos.calm.calm'); + expect(link).toHaveAttribute('title', 'finos.calm'); + expect(link).toHaveAttribute('href', '/namespace/finos.calm'); + }); + + it('renders a synthetic grouping row with no link and no own pill, but a working chevron', () => { + const tree = buildNamespaceTree([nc('finos.calm', 5)]); + const finos = tree[0]; // 'finos' is group-only here + const onToggleCollapsed = vi.fn(); + render( + + + + ); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByTestId('count-badge')).not.toBeInTheDocument(); + expect(screen.getByText('finos')).toHaveStyle({ color: colors.redesign.muted }); + expect(screen.getByText('finos')).toHaveClass('italic'); + + const chevron = screen.getByRole('button', { name: 'Collapse finos' }); + fireEvent.click(chevron); + expect(onToggleCollapsed).toHaveBeenCalledWith('finos'); + }); + + it('shows the ghost pill only when collapsed', () => { + const { rerender } = renderItem({ hasChildren: true, collapsed: false, descendantTotal: 5 }); + expect(screen.queryByTestId('nested-count-badge')).not.toBeInTheDocument(); + + rerender( + + + + ); + expect(screen.getByTestId('nested-count-badge')).toHaveTextContent('+5'); + }); + + it('suppresses the ghost pill while filtering, even when collapsed is somehow true', () => { + renderItem({ hasChildren: true, collapsed: true, descendantTotal: 5, filtering: true }); + expect(screen.queryByTestId('nested-count-badge')).not.toBeInTheDocument(); + }); + + it('renders one indent guide per depth level, capped at 4', () => { + const { container, rerender } = renderItem({ depth: 2 }); + expect(container.querySelectorAll('span[style*="border-left"]')).toHaveLength(2); + + rerender( + + + + ); + expect(container.querySelectorAll('span[style*="border-left"]')).toHaveLength(4); + }); + + it('marks the active row with the accent treatment and aria-current', () => { + renderItem({ active: true }); + const link = screen.getByRole('link', { name: 'finos.calm' }); + expect(link).toHaveStyle({ color: colors.redesign.activeText }); + expect(link).toHaveAttribute('aria-current', 'page'); + }); + + it('highlights the matched substring while filtering', () => { + renderItem({ filtering: true, needle: 'calm' }); + const mark = screen.getByText('calm', { selector: 'mark' }); + expect(mark).toHaveStyle({ backgroundColor: colors.redesign.tintBg, color: colors.redesign.primaryText }); + }); +}); diff --git a/calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.tsx b/calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.tsx new file mode 100644 index 000000000..5231bef48 --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.tsx @@ -0,0 +1,118 @@ +import { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { IoChevronForwardOutline } from 'react-icons/io5'; +import { colors } from '../../../theme/colors.js'; +import { redesignTokens } from '../../../theme/redesign-tokens.js'; +import { CountBadge } from './CountBadge.js'; +import { NestedCountBadge } from './NestedCountBadge.js'; +import { splitOnMatch, type NamespaceTreeNode } from './namespace-tree.js'; + +const MAX_INDENT_GUIDES = 4; + +interface NamespaceRailItemProps { + node: NamespaceTreeNode; + depth: number; + hasChildren: boolean; + collapsed: boolean; + descendantTotal: number; + active: boolean; + /** Filter mode swaps the label to the full path and suppresses the ghost pill. */ + filtering: boolean; + /** Lower-cased filter text, used to highlight the match in the label. */ + needle: string; + onToggleCollapsed: (path: string) => void; +} + +function highlight(label: string, needle: string): ReactNode { + const match = splitOnMatch(label, needle); + if (!match) return label; + return ( + <> + {match.prefix} + {match.match} + {match.suffix} + + ); +} + +/** + * One row of the namespace tree: indent guides, a disclosure chevron (only + * when there are children), and a link-or-plain label. The chevron is a + * sibling of the link rather than a descendant — a ` + ) : ( +
+ ); +} diff --git a/calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.test.tsx b/calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.test.tsx new file mode 100644 index 000000000..5606aef8b --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { NestedCountBadge } from './NestedCountBadge.js'; +import { colors } from '../../../theme/colors.js'; + +describe('NestedCountBadge', () => { + it('renders a plus-prefixed count', () => { + render(); + expect(screen.getByTestId('nested-count-badge')).toHaveTextContent('+8'); + }); + + it('renders with the faint badge treatment', () => { + render(); + const badge = screen.getByTestId('nested-count-badge'); + expect(badge).toHaveStyle({ backgroundColor: colors.redesign.badgeBgFaint, color: colors.redesign.disabled }); + }); +}); diff --git a/calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.tsx b/calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.tsx new file mode 100644 index 000000000..8ff33b3ec --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.tsx @@ -0,0 +1,24 @@ +import { colors } from '../../../theme/colors.js'; + +interface NestedCountBadgeProps { + /** Descendant items hidden under this collapsed row. */ + count: number; +} + +/** + * Ghost `+N` pill shown on a collapsed namespace row for the items hidden + * beneath it. A distinct component (not a {@link CountBadge} variant) so the + * existing `data-testid="count-badge"` keeps meaning "this row's own count" + * everywhere it is asserted. + */ +export function NestedCountBadge({ count }: NestedCountBadgeProps) { + return ( + + +{count} + + ); +} diff --git a/calm-hub-ui/src/hub/components/explore-rail/namespace-tree.test.ts b/calm-hub-ui/src/hub/components/explore-rail/namespace-tree.test.ts new file mode 100644 index 000000000..95f15728b --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/namespace-tree.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { + ancestorPathsOf, + buildNamespaceTree, + filterNamespaceTree, + flattenNamespaceTree, + splitOnMatch, + type NamespaceTreeNode, +} from './namespace-tree.js'; +import type { NamespaceCounts } from '../../../model/counts.js'; + +function nc(namespace: string, total: number): NamespaceCounts { + return { namespace, architectures: 0, patterns: 0, flows: 0, standards: 0, adrs: 0, interfaces: 0, total }; +} + +describe('buildNamespaceTree', () => { + it('gives every dot segment its own row — no path compression', () => { + const tree = buildNamespaceTree([nc('platform.payments.ledger', 3)]); + expect(tree).toHaveLength(1); + + const platform = tree[0]; + expect(platform.segment).toBe('platform'); + expect(platform.path).toBe('platform'); + expect(platform.total).toBeNull(); + expect(platform.children).toHaveLength(1); + + const payments = platform.children[0]; + expect(payments.segment).toBe('payments'); + expect(payments.path).toBe('platform.payments'); + expect(payments.total).toBeNull(); + expect(payments.children).toHaveLength(1); + + const ledger = payments.children[0]; + expect(ledger.segment).toBe('ledger'); + expect(ledger.path).toBe('platform.payments.ledger'); + expect(ledger.total).toBe(3); + expect(ledger.children).toHaveLength(0); + }); + + it('treats traderx and finos.traderx as unrelated roots', () => { + const tree = buildNamespaceTree([nc('traderx', 9), nc('finos.traderx', 4)]); + expect(tree.map((n) => n.path)).toEqual(['finos', 'traderx']); + + const finos = tree.find((n) => n.path === 'finos')!; + expect(finos.total).toBeNull(); + expect(finos.children).toHaveLength(1); + expect(finos.children[0].path).toBe('finos.traderx'); + expect(finos.children[0].total).toBe(4); + + const traderx = tree.find((n) => n.path === 'traderx')!; + expect(traderx.total).toBe(9); + expect(traderx.children).toHaveLength(0); + }); + + it('does not collapse a namespace node even when it has a single child', () => { + const tree = buildNamespaceTree([nc('finos', 10), nc('finos.calm', 5)]); + expect(tree).toHaveLength(1); + expect(tree[0].path).toBe('finos'); + expect(tree[0].total).toBe(10); + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0].path).toBe('finos.calm'); + }); + + it('sorts children by segment and gives an identical shape for shuffled input', () => { + const ordered = buildNamespaceTree([nc('finos.wave', 1), nc('finos.calm', 5), nc('finos.axel', 2)]); + const shuffled = buildNamespaceTree([nc('finos.calm', 5), nc('finos.axel', 2), nc('finos.wave', 1)]); + + expect(ordered[0].children.map((c) => c.segment)).toEqual(['axel', 'calm', 'wave']); + expect(shuffled).toEqual(ordered); + }); + + it('skips malformed entries (blank or all-dot namespaces)', () => { + const tree = buildNamespaceTree([nc('', 1), nc('..', 2), nc('finos', 10)]); + expect(tree).toHaveLength(1); + expect(tree[0].path).toBe('finos'); + }); + + it('handles an empty namespace list', () => { + expect(buildNamespaceTree([])).toEqual([]); + }); +}); + +describe('ancestorPathsOf', () => { + it('returns strict ancestors, nearest root first', () => { + expect(ancestorPathsOf('finos.calm.payments')).toEqual(['finos', 'finos.calm']); + }); + + it('returns an empty array for a root path', () => { + expect(ancestorPathsOf('finos')).toEqual([]); + }); +}); + +describe('filterNamespaceTree', () => { + const tree = buildNamespaceTree([nc('finos', 10), nc('finos.calm', 5), nc('finos.wave', 3), nc('traderx', 9)]); + + it('matches at any depth and marks strict ancestors visible', () => { + const visible = filterNamespaceTree(tree, 'wave'); + expect(visible).toEqual(new Set(['finos.wave', 'finos'])); + }); + + it('brings a matching parent’s subtree along for free', () => { + const visible = filterNamespaceTree(tree, 'finos'); + expect(visible.has('finos')).toBe(true); + expect(visible.has('finos.calm')).toBe(true); + expect(visible.has('finos.wave')).toBe(true); + expect(visible.has('traderx')).toBe(false); + }); + + it('returns an empty set for an empty needle', () => { + expect(filterNamespaceTree(tree, '')).toEqual(new Set()); + }); +}); + +describe('flattenNamespaceTree', () => { + const tree = buildNamespaceTree([nc('finos', 10), nc('finos.calm', 5), nc('finos.wave', 3), nc('traderx', 9)]); + + it('excludes the node’s own total from descendantTotal', () => { + const rows = flattenNamespaceTree(tree, { collapsed: new Set(), filtering: false, visible: new Set() }); + const finos = rows.find((r) => r.node.path === 'finos')!; + expect(finos.node.total).toBe(10); + expect(finos.descendantTotal).toBe(8); + }); + + it('browse mode hides children of a collapsed node', () => { + const rows = flattenNamespaceTree(tree, { collapsed: new Set(['finos']), filtering: false, visible: new Set() }); + expect(rows.map((r) => r.node.path)).toEqual(['finos', 'traderx']); + expect(rows[0].collapsed).toBe(true); + }); + + it('filter mode ignores the collapsed set and emits every visible node', () => { + const visible = new Set(['finos', 'finos.wave']); + const rows = flattenNamespaceTree(tree, { collapsed: new Set(['finos']), filtering: true, visible }); + expect(rows.map((r) => r.node.path)).toEqual(['finos', 'finos.wave']); + expect(rows.every((r) => r.collapsed === false)).toBe(true); + }); +}); + +describe('splitOnMatch', () => { + it('splits around the first case-insensitive occurrence', () => { + expect(splitOnMatch('finos.traderx', 'trade')).toEqual({ prefix: 'finos.', match: 'trade', suffix: 'rx' }); + }); + + it('returns null when there is no match', () => { + expect(splitOnMatch('finos', 'zzz')).toBeNull(); + }); + + it('returns null for an empty needle', () => { + expect(splitOnMatch('finos', '')).toBeNull(); + }); +}); + +describe('no path compression', () => { + it('keeps a single-child non-namespace intermediate as its own row', () => { + const tree: NamespaceTreeNode[] = buildNamespaceTree([nc('org.finos', 2), nc('org.finos.calm', 1)]); + expect(tree).toHaveLength(1); + expect(tree[0].path).toBe('org'); + expect(tree[0].total).toBeNull(); + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0].path).toBe('org.finos'); + }); +}); diff --git a/calm-hub-ui/src/hub/components/explore-rail/namespace-tree.ts b/calm-hub-ui/src/hub/components/explore-rail/namespace-tree.ts new file mode 100644 index 000000000..1b6edc139 --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/namespace-tree.ts @@ -0,0 +1,154 @@ +import { NamespaceCounts } from '../../../model/counts.js'; + +/** + * One row of the dot-prefix namespace tree. `total` is `null` for a synthetic + * grouping-only node — a path segment that is not itself a namespace, only an + * ancestor of one (e.g. `platform` when only `platform.payments.ledger` exists). + */ +export interface NamespaceTreeNode { + /** Full dot-separated path, e.g. `finos.calm`. */ + path: string; + /** Last path segment — what a row shows outside filter mode. */ + segment: string; + total: number | null; + children: NamespaceTreeNode[]; +} + +interface TrieNode { + total: number | null; + children: Map; +} + +function toTreeNode(segment: string, path: string, trie: TrieNode): NamespaceTreeNode { + const children = [...trie.children.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([childSegment, childTrie]) => toTreeNode(childSegment, `${path}.${childSegment}`, childTrie)); + return { path, segment, total: trie.total, children }; +} + +/** + * Builds the dot-prefix trie, one row per segment — no path compression. + * `platform.payments.ledger` renders as three rows: `platform` and + * `platform.payments` are group-only (`total: null`), `ledger` carries the count. + */ +export function buildNamespaceTree(namespaceCounts: NamespaceCounts[]): NamespaceTreeNode[] { + const root: TrieNode = { total: null, children: new Map() }; + for (const nc of namespaceCounts) { + const segments = (nc.namespace ?? '').split('.').filter(Boolean); + if (segments.length === 0) continue; + let current = root; + for (const segment of segments) { + let child = current.children.get(segment); + if (!child) { + child = { total: null, children: new Map() }; + current.children.set(segment, child); + } + current = child; + } + current.total = nc.total; + } + return [...root.children.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([segment, trie]) => toTreeNode(segment, segment, trie)); +} + +/** Strict ancestor paths of `path`, nearest root first — excludes `path` itself. */ +export function ancestorPathsOf(path: string): string[] { + const segments = path.split('.'); + const ancestors: string[] = []; + for (let i = 1; i < segments.length; i++) { + ancestors.push(segments.slice(0, i).join('.')); + } + return ancestors; +} + +/** + * Paths that match `needle` (assumed already lower-cased) plus their strict + * ancestors — a matching parent brings its subtree along for free, since + * `'finos.calm'.includes(needle)` whenever `'finos'.includes(needle)`. + */ +export function filterNamespaceTree(tree: NamespaceTreeNode[], needle: string): Set { + const visible = new Set(); + if (!needle) return visible; + + const visit = (nodes: NamespaceTreeNode[]) => { + for (const node of nodes) { + if (node.path.toLowerCase().includes(needle)) { + visible.add(node.path); + for (const ancestor of ancestorPathsOf(node.path)) { + visible.add(ancestor); + } + } + visit(node.children); + } + }; + visit(tree); + return visible; +} + +function sumDescendantTotals(node: NamespaceTreeNode): number { + let sum = 0; + for (const child of node.children) { + sum += (child.total ?? 0) + sumDescendantTotals(child); + } + return sum; +} + +/** One rendered row: the node, its indent depth, and the state {@link flattenNamespaceTree} derived for it. */ +export interface NamespaceRow { + node: NamespaceTreeNode; + depth: number; + hasChildren: boolean; + /** Only ever true outside filter mode — filtering ignores the collapsed set entirely. */ + collapsed: boolean; + /** Sum of every namespace total under this node, excluding its own — the ghost `+N` count. */ + descendantTotal: number; +} + +interface FlattenOptions { + /** Full paths the user has collapsed. Read only outside filter mode. */ + collapsed: ReadonlySet; + filtering: boolean; + /** From {@link filterNamespaceTree}. Ignored outside filter mode. */ + visible: ReadonlySet; +} + +/** + * Flattens the tree into render order (depth-first, children already sorted). + * Browse mode respects the collapsed set; filter mode ignores it and emits + * every node in `visible`, regardless of collapse state. + */ +export function flattenNamespaceTree(tree: NamespaceTreeNode[], opts: FlattenOptions): NamespaceRow[] { + const rows: NamespaceRow[] = []; + + const visit = (nodes: NamespaceTreeNode[], depth: number) => { + for (const node of nodes) { + if (opts.filtering) { + if (!opts.visible.has(node.path)) continue; + rows.push({ node, depth, hasChildren: node.children.length > 0, collapsed: false, descendantTotal: sumDescendantTotals(node) }); + visit(node.children, depth + 1); + continue; + } + + const collapsed = opts.collapsed.has(node.path); + rows.push({ node, depth, hasChildren: node.children.length > 0, collapsed, descendantTotal: sumDescendantTotals(node) }); + if (!collapsed) { + visit(node.children, depth + 1); + } + } + }; + visit(tree, 0); + return rows; +} + +/** Splits `label` around the first case-insensitive occurrence of `needle`, for `` highlighting. */ +export function splitOnMatch(label: string, needle: string): { prefix: string; match: string; suffix: string } | null { + if (!needle) return null; + const idx = label.toLowerCase().indexOf(needle.toLowerCase()); + if (idx === -1) return null; + return { + prefix: label.slice(0, idx), + match: label.slice(idx, idx + needle.length), + suffix: label.slice(idx + needle.length), + }; +} diff --git a/calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.test.ts b/calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.test.ts new file mode 100644 index 000000000..a8a46e59d --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.test.ts @@ -0,0 +1,109 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useNamespaceTree } from './useNamespaceTree.js'; +import { createMemoryStorage } from '../../../test-support/memory-storage.js'; +import type { NamespaceCounts } from '../../../model/counts.js'; + +const COLLAPSED_KEY = 'calmHub.railCollapsedNamespaces'; + +function nc(namespace: string, total: number): NamespaceCounts { + return { namespace, architectures: 0, patterns: 0, flows: 0, standards: 0, adrs: 0, interfaces: 0, total }; +} + +const namespaceCounts = [nc('finos', 10), nc('finos.calm', 5), nc('finos.wave', 3), nc('traderx', 9)]; + +describe('useNamespaceTree', () => { + it('seeds the collapsed set from storage', () => { + const storage = createMemoryStorage(); + storage.setItem(COLLAPSED_KEY, JSON.stringify(['finos'])); + + const { result } = renderHook(() => useNamespaceTree({ namespaceCounts, needle: '', storage })); + const finos = result.current.rows.find((r) => r.node.path === 'finos')!; + expect(finos.collapsed).toBe(true); + expect(result.current.rows.map((r) => r.node.path)).toEqual(['finos', 'traderx']); + }); + + it('toggle writes and unwrites the collapsed set', () => { + const storage = createMemoryStorage(); + const { result } = renderHook(() => useNamespaceTree({ namespaceCounts, needle: '', storage })); + + act(() => result.current.toggleCollapsed('finos')); + expect(JSON.parse(storage.getItem(COLLAPSED_KEY)!)).toEqual(['finos']); + + act(() => result.current.toggleCollapsed('finos')); + expect(JSON.parse(storage.getItem(COLLAPSED_KEY)!)).toEqual([]); + }); + + it('degrades to fully expanded on corrupt storage, without throwing', () => { + const storage = createMemoryStorage(); + storage.setItem(COLLAPSED_KEY, 'not-json{{{'); + + expect(() => renderHook(() => useNamespaceTree({ namespaceCounts, needle: '', storage }))).not.toThrow(); + const { result } = renderHook(() => useNamespaceTree({ namespaceCounts, needle: '', storage })); + expect(result.current.rows.every((r) => r.collapsed === false)).toBe(true); + }); + + it('degrades to fully expanded when storage throws, without throwing', () => { + const throwingStorage: Storage = { + getItem: () => { + throw new Error('unavailable'); + }, + setItem: () => { + throw new Error('unavailable'); + }, + removeItem: () => undefined, + clear: () => undefined, + key: () => null, + length: 0, + }; + + expect(() => renderHook(() => useNamespaceTree({ namespaceCounts, needle: '', storage: throwingStorage }))).not.toThrow(); + }); + + it('filtering writes nothing to storage — the stored value stays byte-identical', () => { + const storage = createMemoryStorage(); + storage.setItem(COLLAPSED_KEY, JSON.stringify(['finos'])); + const before = storage.getItem(COLLAPSED_KEY); + + const { rerender } = renderHook(({ needle }) => useNamespaceTree({ namespaceCounts, needle, storage }), { + initialProps: { needle: '' }, + }); + rerender({ needle: 'trade' }); + rerender({ needle: '' }); + + expect(storage.getItem(COLLAPSED_KEY)).toBe(before); + }); + + it('filter mode ignores the collapsed set entirely', () => { + const storage = createMemoryStorage(); + storage.setItem(COLLAPSED_KEY, JSON.stringify(['finos'])); + + const { result } = renderHook(() => useNamespaceTree({ namespaceCounts, needle: 'wave', storage })); + expect(result.current.filtering).toBe(true); + expect(result.current.rows.map((r) => r.node.path)).toEqual(['finos', 'finos.wave']); + expect(result.current.rows.every((r) => r.collapsed === false)).toBe(true); + }); + + it('navigating reveals the ancestors of the active namespace', () => { + const storage = createMemoryStorage(); + storage.setItem(COLLAPSED_KEY, JSON.stringify(['finos'])); + + const { result, rerender } = renderHook( + ({ activeNamespace }) => useNamespaceTree({ namespaceCounts, needle: '', activeNamespace, storage }), + { initialProps: { activeNamespace: undefined as string | undefined } } + ); + expect(result.current.rows.map((r) => r.node.path)).toEqual(['finos', 'traderx']); + + rerender({ activeNamespace: 'finos.calm' }); + expect(result.current.rows.map((r) => r.node.path)).toEqual(['finos', 'finos.calm', 'finos.wave', 'traderx']); + expect(JSON.parse(storage.getItem(COLLAPSED_KEY)!)).toEqual([]); + }); + + it('does not clear the saved collapsed set while namespaceCounts is still empty (loading window)', () => { + const storage = createMemoryStorage(); + storage.setItem(COLLAPSED_KEY, JSON.stringify(['finos'])); + + renderHook(() => useNamespaceTree({ namespaceCounts: [], needle: '', storage })); + expect(JSON.parse(storage.getItem(COLLAPSED_KEY)!)).toEqual(['finos']); + }); +}); diff --git a/calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.ts b/calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.ts new file mode 100644 index 000000000..18de04b52 --- /dev/null +++ b/calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { NamespaceCounts } from '../../../model/counts.js'; +import { + ancestorPathsOf, + buildNamespaceTree, + filterNamespaceTree, + flattenNamespaceTree, + type NamespaceRow, +} from './namespace-tree.js'; + +/** Persist which namespaces are collapsed so a refresh keeps the rail as it was. */ +const COLLAPSED_STORAGE_KEY = 'calmHub.railCollapsedNamespaces'; + +function readCollapsed(storage: Storage): Set { + try { + const raw = storage.getItem(COLLAPSED_STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((p): p is string => typeof p === 'string')); + } catch { + return new Set(); + } +} + +interface UseNamespaceTreeOptions { + namespaceCounts: NamespaceCounts[]; + /** Trimmed, lower-cased filter text — empty string means browse mode. */ + needle: string; + /** The namespace the current route resolves to, if any — reveals its ancestors on navigate. */ + activeNamespace?: string; + /** Storage instance for persisting the collapsed set. Defaults to localStorage. Inject a fake in tests. */ + storage?: Storage; +} + +interface UseNamespaceTreeResult { + rows: NamespaceRow[]; + filtering: boolean; + toggleCollapsed: (path: string) => void; +} + +/** + * Composes {@link buildNamespaceTree}/{@link filterNamespaceTree}/{@link flattenNamespaceTree} + * into a render-ready row list, with the collapsed set persisted across + * sessions. Filtering never reads or writes the collapsed set — see + * `namespace-tree.ts`'s `flattenNamespaceTree`. + */ +export function useNamespaceTree({ namespaceCounts, needle, activeNamespace, storage = localStorage }: UseNamespaceTreeOptions): UseNamespaceTreeResult { + const [collapsed, setCollapsed] = useState>(() => readCollapsed(storage)); + + // Reveal-on-navigate: reopen any ancestor of the newly active namespace. + useEffect(() => { + if (!activeNamespace) return; + const ancestors = ancestorPathsOf(activeNamespace); + if (ancestors.length === 0) return; + setCollapsed((prev) => { + if (!ancestors.some((a) => prev.has(a))) return prev; + const next = new Set(prev); + ancestors.forEach((a) => next.delete(a)); + return next; + }); + }, [activeNamespace]); + + useEffect(() => { + try { + storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify([...collapsed])); + } catch { + /* ignore unavailable storage */ + } + }, [collapsed, storage]); + + const toggleCollapsed = useCallback((path: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + const tree = useMemo(() => buildNamespaceTree(namespaceCounts), [namespaceCounts]); + const filtering = needle.length > 0; + const visible = useMemo(() => (filtering ? filterNamespaceTree(tree, needle) : new Set()), [tree, needle, filtering]); + const rows = useMemo(() => flattenNamespaceTree(tree, { collapsed, filtering, visible }), [tree, collapsed, filtering, visible]); + + return { rows, filtering, toggleCollapsed }; +} diff --git a/calm-hub-ui/src/hub/components/tree-navigation/MobileNamespaceRow.tsx b/calm-hub-ui/src/hub/components/tree-navigation/MobileNamespaceRow.tsx new file mode 100644 index 000000000..ae871e393 --- /dev/null +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNamespaceRow.tsx @@ -0,0 +1,84 @@ +import { IoChevronForwardOutline } from 'react-icons/io5'; +import { colors } from '../../../theme/colors.js'; +import { redesignTokens } from '../../../theme/redesign-tokens.js'; +import { CountBadge } from '../explore-rail/CountBadge.js'; +import { NestedCountBadge } from '../explore-rail/NestedCountBadge.js'; +import { type NamespaceRow } from '../explore-rail/namespace-tree.js'; + +const INDENT_PER_DEPTH = 16; + +interface MobileNamespaceRowProps { + row: NamespaceRow; + active: boolean; + onToggleCollapsed: (path: string) => void; + onOpen: (namespace: string) => void; +} + +/** + * One row of the mobile drill-down's namespace tree level. The chevron + * (collapse/expand in place) and the label (drill into the namespace's + * types) are separate 44px-tall tap targets, split by a hairline divider — + * a ` + ) : ( +
+ ); +} diff --git a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx index 6b3635c9d..695fa4716 100644 --- a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; import { MobileNavMenu } from './MobileNavMenu.js'; import type { NamespaceCounts, DomainControlCount } from '../../../model/counts.js'; import { colors } from '../../../theme/colors.js'; +import { createMemoryStorage } from '../../../test-support/memory-storage.js'; vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); @@ -65,16 +66,23 @@ const namespaceCounts = [ ] as NamespaceCounts[]; const domainCounts: DomainControlCount[] = [{ domain: 'security', controlCount: 7 }]; +// A group-only node (`platform`, no namespace of its own) with one real child +// (`platform.payments`) — exercises the nested tree and the group-only case. +const nestedNamespaceCounts = [ + ...namespaceCounts, + { namespace: 'platform.payments', architectures: 1, patterns: 0, flows: 0, standards: 0, adrs: 0, interfaces: 0, total: 1 }, +] as NamespaceCounts[]; + const props = { namespaceCounts, domainCounts, onClose: vi.fn(), }; -const renderMenu = () => +const renderMenu = (overrides: Partial> = {}) => render( - + ); @@ -241,4 +249,83 @@ describe('MobileNavMenu', () => { fireEvent.click(screen.getByText('Control Domains')); expect(await screen.findByText("Couldn't load control domains")).toBeInTheDocument(); }); + + describe('namespace tree', () => { + it('nests a child namespace under its parent', async () => { + renderMenu({ namespaceCounts: nestedNamespaceCounts, storage: createMemoryStorage() }); + fireEvent.click(screen.getByText('Namespaces')); + + expect(await screen.findByText('platform')).toBeInTheDocument(); + expect(screen.getByText('payments')).toBeInTheDocument(); + }); + + it('expands and collapses the chevron in place, without changing level or closing the drawer', async () => { + renderMenu({ namespaceCounts: nestedNamespaceCounts, storage: createMemoryStorage() }); + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('payments')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Collapse platform')); + expect(screen.queryByText('payments')).not.toBeInTheDocument(); + // Still on the namespaces level, drawer still open. + expect(screen.getByRole('heading', { name: 'Namespaces' })).toBeInTheDocument(); + expect(props.onClose).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByLabelText('Expand platform')); + expect(await screen.findByText('payments')).toBeInTheDocument(); + }); + + it('shows the hidden descendant count only while a row is collapsed', async () => { + renderMenu({ namespaceCounts: nestedNamespaceCounts, storage: createMemoryStorage() }); + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('payments')).toBeInTheDocument(); + expect(screen.queryByTestId('nested-count-badge')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Collapse platform')); + expect(screen.getByTestId('nested-count-badge')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Expand platform')); + expect(await screen.findByText('payments')).toBeInTheDocument(); + expect(screen.queryByTestId('nested-count-badge')).not.toBeInTheDocument(); + }); + + it("still opens the namespace's types list when its label is tapped", async () => { + renderMenu({ namespaceCounts: nestedNamespaceCounts, storage: createMemoryStorage() }); + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('payments')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('payments')); + expect(await screen.findByText('Architectures')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'platform.payments' })).toBeInTheDocument(); + }); + + it("does nothing when a group-only row's label is tapped, but its chevron still works", async () => { + renderMenu({ namespaceCounts: nestedNamespaceCounts, storage: createMemoryStorage() }); + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('platform')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('platform')); + // No types level was opened — still on the namespaces list. + expect(screen.getByRole('heading', { name: 'Namespaces' })).toBeInTheDocument(); + expect(screen.getByText('payments')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Collapse platform')); + expect(screen.queryByText('payments')).not.toBeInTheDocument(); + }); + + it('persists expansion state across a remount via injected storage', async () => { + const storage = createMemoryStorage(); + const { unmount } = renderMenu({ namespaceCounts: nestedNamespaceCounts, storage }); + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('payments')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Collapse platform')); + expect(screen.queryByText('payments')).not.toBeInTheDocument(); + unmount(); + + renderMenu({ namespaceCounts: nestedNamespaceCounts, storage }); + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('platform')).toBeInTheDocument(); + expect(screen.queryByText('payments')).not.toBeInTheDocument(); + }); + }); }); diff --git a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx index 6e47362d2..348030359 100644 --- a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx @@ -20,6 +20,8 @@ import { } from './navigation-loaders.js'; import { ExplorerSearch } from '../../../components/navbar/ExplorerSearch.js'; import { LoadingSpinner } from '../LoadingSpinner.js'; +import { useNamespaceTree } from '../explore-rail/useNamespaceTree.js'; +import { MobileNamespaceRow } from './MobileNamespaceRow.js'; const RESOURCE_TYPES: TypeInUI[] = ['Architectures', 'Patterns', 'Flows', 'Standards', 'ADRs', 'Interfaces']; @@ -38,6 +40,8 @@ interface MobileNavMenuProps { domainsFailed?: boolean; /** Dismiss the menu (e.g. after a resource is chosen). */ onClose: () => void; + /** Storage for the namespace tree's persisted collapsed set. Defaults to localStorage. Inject a fake in tests. */ + storage?: Storage; } type HubParams = { @@ -65,10 +69,12 @@ interface LeafItem { /** * Mobile navigation as an iOS-style drill-down: each tap pushes the next level - * as a flat list rather than expanding an inline tree. Leaf taps navigate to the - * resource URL; deep-link loading is owned by {@link Hub}'s `useResourceFromRoute` - * (a single shared owner), so this panel only drives navigation and its own - * drill-down list state. + * as a flat list rather than expanding an inline tree — except the `namespaces` + * level, which nests the dot-prefix namespace tree in place ({@link MobileNamespaceRow}), + * since that hierarchy is already latent in the dotted names. Leaf taps navigate + * to the resource URL; deep-link loading is owned by {@link Hub}'s + * `useResourceFromRoute` (a single shared owner), so this panel only drives + * navigation and its own drill-down list state. * * Phase 1 adds a mono count badge per namespace/domain row and a brand-tint * active treatment for the row matching the current URL. Counts are owned by @@ -83,6 +89,7 @@ export function MobileNavMenu({ namespacesFailed, domainsFailed, onClose, + storage = localStorage, }: MobileNavMenuProps) { const navigate = useNavigate(); const params = useParams(); @@ -100,13 +107,14 @@ export function MobileNavMenu({ // Derive the namespace/domain lists from the counts Hub already fetched, rather than // re-fetching them here. Avoids two redundant requests and keeps the row labels in the // same snapshot as the count badges. - const namespaces = useMemo(() => namespaceCounts.map((c) => c.namespace), [namespaceCounts]); const domains = useMemo(() => domainCounts.map((c) => c.domain), [domainCounts]); - const namespaceTotal = useCallback( - (ns: string) => namespaceCounts.find((c) => c.namespace === ns)?.total, - [namespaceCounts] - ); + const { rows: namespaceRows, toggleCollapsed } = useNamespaceTree({ + namespaceCounts, + needle: '', + activeNamespace: params.ns ?? params.namespace, + storage, + }); const domainControlCount = useCallback( (d: string) => domainCounts.find((c) => c.domain === d)?.controlCount, [domainCounts] @@ -255,15 +263,9 @@ export function MobileNavMenu({ { key: 'namespaces', label: 'Namespaces', isLeaf: false, onClick: () => setView({ level: 'namespaces' }) }, { key: 'domains', label: 'Control Domains', isLeaf: false, onClick: () => setView({ level: 'domains' }) }, ]; + // Namespaces render as a nested tree via `namespaceRows`, not a flat Row list. case 'namespaces': - return namespaces.map((ns) => ({ - key: ns, - label: ns, - isLeaf: false, - count: namespaceTotal(ns), - active: ns === params.ns || ns === params.namespace, - onClick: () => setView({ level: 'types', namespace: ns }), - })); + return []; case 'types': return RESOURCE_TYPES.map((t) => { const count = typeCount(view.namespace, t); @@ -315,7 +317,7 @@ export function MobileNavMenu({ // "Loading" that doesn't tell a screen-reader user which section. const loadingLabel = view.level === 'namespaces' ? 'Loading namespaces' : view.level === 'domains' ? 'Loading control domains' : 'Loading'; - const isEmpty = !showLoading && rows.length === 0; + const isEmpty = !showLoading && (view.level === 'namespaces' ? namespaceRows.length === 0 : rows.length === 0); // Distinguish "the fetch failed" from "there's genuinely nothing here" — a // failed counts fetch is unknown, not zero (mirrors Hub's own namespaceCountsFailed). // No retry action exists here (Hub fetches counts once on mount), so the copy @@ -355,7 +357,19 @@ export function MobileNavMenu({ {isEmpty && (
  • {emptyMessage}
  • )} + {!showLoading && view.level === 'namespaces' && + namespaceRows.map((nr) => ( +
  • + setView({ level: 'types', namespace })} + /> +
  • + ))} {!showLoading && + view.level !== 'namespaces' && rows.map((row) => (