Skip to content

feat(calm-hub-ui): nest namespaces in the Explore rail - #3119

Open
YoofiTT96 wants to merge 7 commits into
finos:mainfrom
YoofiTT96:feat/2787-namespace-hierarchy-rail
Open

YoofiTT96 wants to merge 7 commits into
finos:mainfrom
YoofiTT96:feat/2787-namespace-hierarchy-rail

Conversation

@YoofiTT96

@YoofiTT96 YoofiTT96 commented Sep 16, 2026

Copy link
Copy Markdown
Member

Description

A CALM Hub namespace has a dotted name. The hierarchy is therefore already in the name. The Explore rail did not use it. It drew finos.calm and finos.traderx as siblings of finos, not below it. This is easy to read at demo scale. It does not scale to an enterprise with hundreds of namespaces at several levels. @markscott-ms raised this point on the redesign epic #2754.

The rail now nests rows on the dot prefix, and the filter flattens the tree to the matches. Collapsing the rail keeps that structure as a strip of root initials with a fly-out. The mobile drawer shows the same tree.

Closes #2787.

Visual Sample

image

Nested View

Screenshot 2026-09-16 at 3 32 50 PM

Filtered Search

image

Collapsed View with pop out. Namespaces with same first character appear duplicated however the pop out provides a form of disambiguation

image

Mobile View

Decisions worth knowing

A row has two hit targets. The chevron opens and closes the subtree. The label opens the page of that namespace. A prefix such as finos owns artefacts and also parents other namespaces, so a user must be able to do each action separately. This is why a namespace row is a new component. The existing RailItem is a single link, and it stays untouched for the CONTROL DOMAINS rows.

Two counts, two weights. The solid pill is the own count of the row, unchanged from today. The ghost +N pill is the sum the descendants hold, and a row shows it only while closed. The two add. finos owning 10 with children of 5 and 3 reads +8 and [10], so the branch holds 18.

The rail stores the closed rows, not the open ones. Every row is open by default, so each namespace visible before this change is still visible after it. Storing the open rows would show only roots on a first load, which reads as lost data.

Filtering never writes to that stored set. A filter opens ancestors to reveal a match, but it does so in the render only. Clearing the filter restores exactly what the user chose.

No role="tree". That ARIA pattern wants one focusable element per row under a roving tabindex, which is incompatible with the two hit targets above. The rows are disclosure buttons and links in the natural tab order instead. This codebase has no role="tree" anywhere.

Reuse

The trie builder is recovered from TreeNavigation.tsx, which commit 61047513 deleted when the flat rail replaced the old tree, along with its unit tests. Two changes are deliberate: it no longer compresses paths, so each segment gets a row, and it now sorts siblings rather than relying on insertion order.

The client tree agrees with the rule the server already uses to decide what a child namespace is.

Gotchas

The namespace href now runs through encodeURIComponent. That changes nothing for any name the server permits. It is protection against a malformed payload, not a correction of a fault.

The rail is 236px wide. A filter shows full paths so finos.traderx stays distinct from traderx, and a match four levels deep will still truncate.

The collapsed strip previously floated as a rounded card inside grey padding, while the expanded rail sits flush. It now matches the expanded rail, so collapsing narrows the rail rather than swapping one shape for another.

Mobile, and a convention this changes

Mobile does not render this rail. Hub.tsx draws a separate drill-down component, MobileNavMenu, where each tap replaces the list with the next flat level. Its namespaces level now renders the same tree, with a 40px chevron column and a label as separate targets, each at least 44px tall. A closed row shows the same ghost +N pill the desktop rail does. Expansion persists through the same stored set the desktop rail uses.

One deliberate departure from the issue. #2787 says the mobile label should navigate to the namespace page and dismiss the drawer. 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. The label therefore keeps its present behaviour and opens the types list. The chevron carries the new expand action.

Mobile has no namespace filter, deliberately. The drawer already carries one text input, ExplorerSearch, which queries the hub and replaces the list rather than narrowing it. A second input filtering the list beneath it would put two text fields with different jobs at the top of a 390px drawer. Drilling into a branch is the mobile equivalent of filtering, so the tree is built with filtering switched off there.

That leaves one asymmetry worth knowing. Both surfaces share a single stored set of closed rows, so a branch closed on the desktop rail is closed in the drawer too. On the desktop a filter reveals it regardless, because filtering ignores that set. On mobile the chevron is the only way back. The default is fully open, so this reaches only a user who closed the branch on purpose.

This changes a documented convention, so it needs a reviewer's eye. calm-hub-ui/AGENTS.md said the mobile explorer is one flat list per level and never a tree. That rule predates #2787 by sixteen days, and #2787 asks for a tree in the component the rule names. The rule is now narrowed rather than dropped: it still governs the types, resources, domains and controls levels, and names the namespaces level as the exception.

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Code style/formatting changes
  • ♻️ Refactoring (no functional changes)
  • ⚡ Performance improvements
  • ✅ Test additions or updates
  • 🔧 Chore (maintenance, dependencies, CI, etc.)

The API does not change. The data model does not change. The hierarchy is a pure function of names the counts endpoint already returns.

Affected Components

  • CLI (cli/)
  • Schema (calm/)
  • CALM AI (calm-ai/)
  • CALM Hub (calm-hub/)
  • CALM Hub UI (calm-hub-ui/)
  • CALM Server (calm-server/)
  • CALM Widgets (calm-widgets/)
  • Documentation (docs/)
  • Shared (shared/)
  • VS Code Extension (calm-plugins/vscode/)
  • Dependencies
  • CI/CD

Commit Message Format ✅

feat(calm-hub-ui): nest namespaces in the Explore rail
feat(calm-hub-ui): show namespace roots in the collapsed rail

Testing

  • I have tested my changes locally
  • I have added/updated unit tests
  • All existing tests pass

This PR adds 44 tests. Every existing test passes without a change, including all twelve ExploreRail.test.tsx cases and the Hub.test.tsx expectations for the collapse button. That was a constraint of the design, not a hoped-for result.

One decision carries the most weight, so I checked it by mutation. If the flatten step reads the stored set of closed rows while a filter is active, exactly three tests fail — one in the pure module, one in the hook, one in the integration suite.

calm-hub-ui passes 135 files and 1610 tests. Lint reports 0 errors and 9 warnings, all pre-existing and none in a new file. The build is clean.

Checked by hand against a standalone hub holding finos, finos.calm, finos.traderx, traderx and test-ns, in both the expanded and the collapsed state.

On viewport coverage: this rail is not drawn on mobile at all, so there is no mobile render of it to test. The suite for the mobile drawer is untouched and passes.

Checklist

  • My commits follow the conventional commit format
  • I have updated documentation if necessary
  • I have added tests for my changes (if applicable)
  • My changes follow the project's coding standards

🤖 Generated with Claude Code

@YoofiTT96
YoofiTT96 requested a review from a team as a code owner September 16, 2026 15:28
@github-actions github-actions Bot added the calm-hub-ui Affects `calm-hub-ui` label Sep 16, 2026
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
6104751 deleted. Its path compression is dropped, so each segment gets
its own row.

Part of finos#2787. The fly-out for the closed rail follows in a second PR.
@YoofiTT96
YoofiTT96 force-pushed the feat/2787-namespace-hierarchy-rail branch from 96ab4fd to bfed4bb Compare September 16, 2026 15:31
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 finos#2787.
@YoofiTT96
YoofiTT96 marked this pull request as draft September 17, 2026 10:08
YoofiTT96 and others added 3 commits September 17, 2026 10:08
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. finos#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 finos#2787.
@YoofiTT96
YoofiTT96 marked this pull request as ready for review September 17, 2026 16:54

@aamanrebello aamanrebello left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was run with AI-assisted tooling (Claude Code) against this branch's diff. Sorry for the comment volume from an AI pass — please feel free to respond on any comment you think does not apply, or where the tradeoff was intentional.

))}
</div>

{hasChildren ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chevron button renders whenever hasChildren is true, without checking filtering. In filter mode, flattenNamespaceTree always returns collapsed: false and ignores the collapsed set, so clicking the chevron here has no visible effect — but it still calls onToggleCollapsed, which flips the entry in the persisted collapse set. After the user clears the filter, a namespace can appear collapsed or expanded that they never consciously toggled. Consider disabling the toggle (or hiding the button) while filtering is true.

} from './namespace-tree.js';

/** Persist which namespaces are collapsed so a refresh keeps the rail as it was. */
const COLLAPSED_STORAGE_KEY = 'calmHub.railCollapsedNamespaces';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COLLAPSED_STORAGE_KEY is one global key, but this hook is used by both ExploreRail (desktop) and MobileNavMenu (mobile), both defaulting storage to localStorage. Collapsing a namespace on one surface silently changes the starting state on the other, even though they show different node sets. Consider namespacing the key per surface (e.g. a storageKey param).

if (!isWithin(e.currentTarget, e.relatedTarget)) onClose();
}}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape closes the fly-out unconditionally, which unmounts the {isOpen && (...)} block. If a keyboard user has tabbed onto one of the fly-out's Link rows, that element disappears and the browser drops focus to <body> — nothing here returns focus to the trigger button. Worth keeping a ref to the trigger and calling .focus() on it after closing (the same pattern onBlur already uses for detecting focus movement).

<div
className="relative"
onMouseEnter={onOpen}
onMouseLeave={onClose}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onMouseLeave closes the fly-out unconditionally, unlike onBlur just below it, which checks isWithin before closing. A hybrid mouse+keyboard user who opens the fly-out via Tab (onFocus) and then nudges the mouse across/off the container will have it closed even though focus is still logically inside. Might be worth mirroring the isWithin check here, or dropping onMouseLeave in favor of onBlur.

<div
className="absolute top-0 left-full ml-1 flex flex-col gap-0.5 p-1.5 rounded-[12px] z-50"
style={{
width: 220,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This panel has a fixed width: 220 and no maxHeight/overflowY, and rows is built with collapsed: new Set() — so it always renders the full subtree regardless of what the user collapsed on the main rail. For a root with a large or deep subtree this can overflow the viewport with no way to scroll. A capped maxHeight plus overflowY: 'auto' would be a cheap safety net.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.filter(Boolean) drops empty path segments, so e.g. "billing..invoices" normalizes to the same trie path as a legitimate "billing.invoices". If both exist, whichever is processed second silently overwrites current.total for the first, and the rendered row links via the normalized path, not the original string. Not sure if malformed namespace strings like this can actually reach this component — if they can't (e.g. validated upstream), feel free to disregard.

{namespaceCounts.length === 0 ? 'Nothing here' : 'No namespaces match your filter'}
</RailEmpty>
) : rows.length === 0 ? (
<RailEmpty>{needle === '' ? 'Nothing here' : 'No namespaces match your filter'}</RailEmpty>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to check namespaceCounts.length === 0 for the "nothing fetched" message; now it checks needle === ''. These aren't equivalent — if namespaceCounts is non-empty but buildNamespaceTree/flattenNamespaceTree ends up producing zero rows for some other reason (e.g. the malformed-namespace case noted on namespace-tree.ts:37), this now reports "Nothing here" as if the fetch failed, even though it didn't. Might be worth deriving this from whether the tree actually produced rows rather than from the filter text.

className="font-mono-jb text-[11px] leading-none px-1.5 py-0.5 rounded-md min-w-[20px] text-center inline-block"
style={{ backgroundColor: colors.redesign.badgeBgFaint, color: colors.redesign.disabled }}
>
+{count}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No guard for count === 0 — if a collapsed namespace's descendants are all zero-total, this renders a "+0" pill, implying hidden content that isn't there. A descendantTotal > 0 check at the call site (or a guard here) would avoid it.

path: string;
/** Last path segment — what a row shows outside filter mode. */
segment: string;
total: number | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node.total !== null (the "group-only vs. real namespace" check) is independently re-derived in NamespaceRailItem.tsx:55, CollapsedRail.tsx:20, and MobileNamespaceRow.tsx:25. Not a bug today, but if this sentinel ever changes, a fix applied to one and missed in another would silently make a namespace clickable in some views and not others. Might be worth an isNamespace(node) helper exported from here — could easily be a follow-up rather than part of this PR.

const isNamespace = node.total !== null;

return (
<div className="flex items-stretch" style={{ paddingLeft: depth * INDENT_PER_DEPTH }}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation-per-depth is implemented three different, uncoordinated ways: NamespaceRailItem.tsx caps at MAX_INDENT_GUIDES = 4 (14px each), CollapsedRail.tsx's FlyoutRow uses 8 + depth * 14 uncapped, and this uses depth * 16 uncapped. A namespace nested 10 levels deep would push 160px of padding on a mobile row, with no cap like the desktop rail has. Depends on how deep namespaces realistically nest in practice — if it's rare this might not matter, but a shared cap (mirroring the desktop pattern) would be cheap insurance.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

calm-hub-ui Affects `calm-hub-ui`

Projects

None yet

Development

Successfully merging this pull request may close these issues.

calm-hub-ui: represent the namespace hierarchy in the Explore rail (collapsible dot-prefix tree)

2 participants