From bfed4bbf40e678fd480f7869292db6e9658b08f8 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Wed, 16 Sep 2026 15:28:02 +0000 Subject: [PATCH 1/3] feat(calm-hub-ui): nest namespaces in the Explore rail A namespace has a dotted name, so the hierarchy is already in the name. The rail did not use it. It drew finos.calm and finos.traderx as siblings of finos rather than below it, which does not scale to an enterprise with hundreds of namespaces. The rows now nest on the dot prefix. A row has two hit targets. The chevron opens and closes the subtree. The label opens the page of that namespace. A prefix that no namespace claims draws as a group-only row, muted and not clickable. A filter flattens the tree to the matches at any depth and shows full names, so finos.traderx stays distinct from traderx. The trie builder is recovered from TreeNavigation.tsx, which commit 61047513 deleted. Its path compression is dropped, so each segment gets its own row. Part of #2787. The fly-out for the closed rail follows in a second PR. --- .../explore-rail/ExploreRail.test.tsx | 79 +++++++- .../components/explore-rail/ExploreRail.tsx | 37 ++-- .../explore-rail/NamespaceRailItem.test.tsx | 174 ++++++++++++++++++ .../explore-rail/NamespaceRailItem.tsx | 118 ++++++++++++ .../explore-rail/NestedCountBadge.test.tsx | 17 ++ .../explore-rail/NestedCountBadge.tsx | 24 +++ .../explore-rail/namespace-tree.test.ts | 161 ++++++++++++++++ .../components/explore-rail/namespace-tree.ts | 154 ++++++++++++++++ .../explore-rail/useNamespaceTree.test.ts | 109 +++++++++++ .../explore-rail/useNamespaceTree.ts | 90 +++++++++ 10 files changed, 946 insertions(+), 17 deletions(-) create mode 100644 calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.test.tsx create mode 100644 calm-hub-ui/src/hub/components/explore-rail/NamespaceRailItem.tsx create mode 100644 calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.test.tsx create mode 100644 calm-hub-ui/src/hub/components/explore-rail/NestedCountBadge.tsx create mode 100644 calm-hub-ui/src/hub/components/explore-rail/namespace-tree.test.ts create mode 100644 calm-hub-ui/src/hub/components/explore-rail/namespace-tree.ts create mode 100644 calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.test.ts create mode 100644 calm-hub-ui/src/hub/components/explore-rail/useNamespaceTree.ts 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 }; +} From 7ad2522ac2ef9d64b53b1fbc92fdb6b42e9ff1f0 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Wed, 16 Sep 2026 16:19:45 +0000 Subject: [PATCH 2/3] feat(calm-hub-ui): show namespace roots in the collapsed rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsing the rail unmounted it and left a 48px strip holding only an expand chevron. The rail now keeps its content: one initial per root namespace, accented when the active namespace sits under it, and a fly-out of that subtree on hover or focus. The fly-out is transient, so it carries no collapse state and no chevrons. It flattens the subtree in full. The collapsed strip now matches the expanded rail — same surface, same right border, flush to the edge — rather than floating as a rounded card inside grey padding. Part of #2787. --- calm-hub-ui/src/hub/Hub.tsx | 17 +- .../explore-rail/CollapsedRail.test.tsx | 92 +++++++++++ .../components/explore-rail/CollapsedRail.tsx | 153 ++++++++++++++++++ 3 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.test.tsx create mode 100644 calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.tsx 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))} + /> + ))} +
+
+ ); +} From 5bbd9bc1550ee58b24bf17722afb3d42ffd5974d Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Thu, 17 Sep 2026 10:49:39 +0000 Subject: [PATCH 3/3] feat(calm-hub-ui): nest namespaces in the mobile drawer The mobile drawer showed namespaces as a flat list, so the hierarchy was visible on desktop only. The namespaces level now renders the same tree. A row has a 40px chevron column and a label, each at least 44px tall. The chevron opens and closes children in place and the drawer stays open. Expansion persists through the same stored set the desktop rail uses. The label keeps its present behaviour and opens that namespace's resource types. #2787 asks it to navigate and dismiss the drawer, but that tap is the only route into the types and resources levels, so following it would leave both unreachable and remove mobile's only path from the drawer to a specific architecture. calm-hub-ui/AGENTS.md said the mobile explorer is one flat list per level and never a tree. That rule predates this issue. It now covers the types, resources, domains and controls levels, and names the namespaces level as the exception. Closes #2787. --- calm-hub-ui/AGENTS.md | 4 +- .../tree-navigation/MobileNamespaceRow.tsx | 84 +++++++++++++++++ .../tree-navigation/MobileNavMenu.test.tsx | 91 ++++++++++++++++++- .../tree-navigation/MobileNavMenu.tsx | 50 ++++++---- 4 files changed, 208 insertions(+), 21 deletions(-) create mode 100644 calm-hub-ui/src/hub/components/tree-navigation/MobileNamespaceRow.tsx 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/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) => (