diff --git a/calm-hub-ui/src/hub/Hub.test.tsx b/calm-hub-ui/src/hub/Hub.test.tsx index 9dc6215a5b..2f7ed7d3f0 100644 --- a/calm-hub-ui/src/hub/Hub.test.tsx +++ b/calm-hub-ui/src/hub/Hub.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; import Hub from './Hub.js'; import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest'; import { authStore } from '../service/utils/auth-store.js'; +import { CountsService } from '../service/counts-service.js'; import type { Data, Adr } from '../model/calm.js'; import type { ControlData } from '../model/control.js'; import type { InterfaceData } from '../model/interface.js'; @@ -475,6 +476,14 @@ describe('Hub', () => { expect(await screen.findByTestId('domain-page')).toHaveTextContent('Domain: security (3)'); }); + it('passes an unknown (undefined) control count, not a misleading 0, when the domain counts fetch fails', async () => { + // A failed fetch means the count is unknown, not a confirmed zero — + // the same distinction Hub already makes for namespace counts. + vi.spyOn(CountsService.prototype, 'fetchDomainCounts').mockRejectedValueOnce(new Error('boom')); + renderAt('/domain/security'); + expect(await screen.findByTestId('domain-page')).toHaveTextContent('Domain: security ()'); + }); + it('renders the intro (not a namespace/domain page) on the empty / route', async () => { renderAt('/'); expect(screen.queryByTestId('namespace-page')).not.toBeInTheDocument(); diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index a68f259611..5e9e5851fb 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -69,6 +69,8 @@ export default function Hub() { const [namespaceCountsFailed, setNamespaceCountsFailed] = useState(false); const [domainCounts, setDomainCounts] = useState([]); const [domainCountsLoaded, setDomainCountsLoaded] = useState(false); + // Mirrors namespaceCountsFailed above — a failed fetch means "unknown", not "zero". + const [domainCountsFailed, setDomainCountsFailed] = useState(false); const isMobile = useIsMobile(); // Route-first content selection (redesign problem #4): the same element @@ -112,7 +114,10 @@ export default function Hub() { countsService .fetchDomainCounts() .then(setDomainCounts) - .catch(() => setDomainCounts([])) + .catch(() => { + setDomainCounts([]); + setDomainCountsFailed(true); + }) .finally(() => setDomainCountsLoaded(true)); }, [countsService]); @@ -340,20 +345,26 @@ export default function Hub() { } ); }, [namespaceCounts, namespaceCountsLoaded, namespaceCountsFailed, activeNamespace]); - // Both counts stay `undefined` until the domain-counts fetch settles, so a - // deep-link shows "controls" rather than a misleading "0 controls" before it - // resolves (mirrors the activeNamespaceCounts gate above). + // Both counts stay `undefined` until the domain-counts fetch settles OR if it + // failed, so a deep-link shows "controls" rather than a misleading "0 controls" + // (mirrors the activeNamespaceCounts gate above). const domainControlCount = useMemo( - () => (domainCountsLoaded ? (domainCounts.find((c) => c.domain === activeDomain)?.controlCount ?? 0) : undefined), - [domainCounts, domainCountsLoaded, activeDomain] + () => + !domainCountsLoaded || domainCountsFailed + ? undefined + : (domainCounts.find((c) => c.domain === activeDomain)?.controlCount ?? 0), + [domainCounts, domainCountsLoaded, domainCountsFailed, activeDomain] ); // Count for the grid shown behind a selected control's panel — the control's own // domain, which may differ from the route's activeDomain when reached via the // detail route (deep-link / mobile drill-down). const controlDomain = controlData?.domain; const controlDomainCount = useMemo( - () => (domainCountsLoaded ? (domainCounts.find((c) => c.domain === controlDomain)?.controlCount ?? 0) : undefined), - [domainCounts, domainCountsLoaded, controlDomain] + () => + !domainCountsLoaded || domainCountsFailed + ? undefined + : (domainCounts.find((c) => c.domain === controlDomain)?.controlCount ?? 0), + [domainCounts, domainCountsLoaded, domainCountsFailed, controlDomain] ); // Chrome-free intro / front door (`/` with nothing else active): early-returns @@ -465,6 +476,10 @@ export default function Hub() { setIsSidebarOpen(false)} /> ) : ( @@ -498,6 +513,10 @@ export default function Hub() { setIsMobileNavOpen(false)} /> diff --git a/calm-hub-ui/src/hub/components/LoadingSpinner.tsx b/calm-hub-ui/src/hub/components/LoadingSpinner.tsx new file mode 100644 index 0000000000..ca15e6e21f --- /dev/null +++ b/calm-hub-ui/src/hub/components/LoadingSpinner.tsx @@ -0,0 +1,9 @@ +/** + * Shared spinner markup for the drill-down/browse navigation surfaces + * (ExploreRail, MobileNavMenu). Callers own their own wrapper element and + * spacing (a rail `
` vs a mobile `
  • `), since those differ by context — + * only the spinner itself needs to stay identical between them. + */ +export function LoadingSpinner({ label }: { label: string }) { + return ; +} diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx index af69eaa55f..150f72ba16 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; import { IoChevronUpOutline } from 'react-icons/io5'; import { colors } from '../../../../theme/colors.js'; import { TimelineHeader } from './TimelineHeader.js'; @@ -24,6 +24,9 @@ interface ContextMenuState { y: number; } +/** Label box width; labels are clamped to the track so this can never leave the card (#2728). */ +const LABEL_WIDTH = 120; + /** * Collapsed timeline strip — a "Browse versions" header (with the current-version * pill) above a sparkline of version dots with title + date below each. Single @@ -62,11 +65,7 @@ export function Sparkline({ // Progress overlay: left edge to the viewed dot in single mode; between // FROM and TO in compare mode. const progressLeft = comparing && fromIdx >= 0 && toIdx >= 0 ? pct(Math.min(fromIdx, toIdx)) : 0; - const progressRight = comparing && fromIdx >= 0 && toIdx >= 0 - ? pct(Math.max(fromIdx, toIdx)) - : viewedIdx >= 0 - ? pct(viewedIdx) - : 0; + const progressRight = comparing && fromIdx >= 0 && toIdx >= 0 ? pct(Math.max(fromIdx, toIdx)) : viewedIdx >= 0 ? pct(viewedIdx) : 0; useEffect(() => { if (!menu) return; @@ -151,19 +150,14 @@ export function Sparkline({ {/* Track row. A single-version resource has nothing to scrub, so the track is suppressed (the version pill already states what's shown). - overflow-hidden clips long labels at the track edge (#2728). */} + Labels are bounded by LABEL_WIDTH + ellipsis, and clamped to stay + inside the track — and so inside DiagramSection's overflow-hidden + card — for any dot, not just the true first/last (#2728). */} {!singleVersion && ( -
    +
    {/* Inner track wrapper inset 10px each side so dot percentages map directly */}
    -
    +
    {progressRight > progressLeft && (
    - +
    - {moment.label} +
    - {moment.validFrom && ( + {/* Positioned relative to the same inset wrapper as the dots (not + the 32px dot column) so clamp()'s percentages resolve against + the full track width — required for the clamp to be able to + keep the label inside the track for any dot, not just i===0/total-1. */} +
    - {moment.validFrom} + {moment.label}
    - )} -
    + {moment.validFrom && ( +
    + {moment.validFrom} +
    + )} +
    + ); })}
    diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx index 360e83d3e8..5f9c4d7dd5 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.test.tsx @@ -257,12 +257,38 @@ describe('TimelineBar', () => { // #2728 — long moment names must not block the expand control or clip cards. describe('long moment names are bounded (#2728)', () => { - it('clips the collapsed sparkline track so labels cannot paint over the expand button', () => { + it('anchors the first and last collapsed labels so they cannot leave the card', () => { renderBar(); - // The centre track is clipped so an overlong label can never overflow - // out to cover the statically-positioned expand button. - expect(screen.getByTestId('timeline-sparkline-track')).toHaveStyle({ - overflow: 'hidden', + // The first/last dots sit close to the card edge, so a centred label + // would overflow the ancestor card's overflow-hidden boundary and be + // sliced. Edge labels grow inward instead of centering (#2728). + expect(screen.getByText('1.0.0')).toHaveStyle({ textAlign: 'left' }); + expect(screen.getByText('1.5.0')).toHaveStyle({ textAlign: 'center' }); + expect(screen.getByText('2.0.0')).toHaveStyle({ textAlign: 'right' }); + }); + + it('clamps every label to the track, not just the true first/last dot', () => { + // With enough versions, a *non-edge* dot (e.g. the 2nd of many) can sit + // close enough to the edge that centering its label would still overflow + // the card. A fixed set of 3 moments can't exercise this — the fix must + // hold for any dot count and track width, which a per-dot clamp() gives us + // (rather than only special-casing i===0/total-1). + const many: TimelineMoment[] = Array.from({ length: 12 }, (_, i) => ({ + key: `m${i}`, + label: `${i}.0.0`, + version: `${i}.0.0`, + })); + render( + + ); + // Dot 1 of 12 sits at 1/11 ≈ 9.09% along the track — close enough to the + // left edge that a centred 120px-wide label would still overflow. Its + // clamp() expression must reflect that dot's own position, not the + // static 50%-centered value the old per-index special case fell back to + // for every non-edge dot. + const secondLabel = screen.getByText('1.0.0'); + expect(secondLabel.parentElement).toHaveStyle({ + left: 'clamp(0px, calc(9.090909090909092% - 60px), calc(100% - 120px))', }); }); 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 c20af66e39..85d8a29439 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 @@ -14,7 +14,15 @@ const domainCounts: DomainControlCount[] = [ { domain: 'compliance', controlCount: 0 }, ]; -const renderRail = (path = '/', onCollapse?: () => void) => +interface RenderRailOptions { + onCollapse?: () => void; + namespacesLoading?: boolean; + domainsLoading?: boolean; + namespacesFailed?: boolean; + domainsFailed?: boolean; +} + +const renderRail = (path = '/', opts: RenderRailOptions = {}) => render( @@ -26,7 +34,11 @@ const renderRail = (path = '/', onCollapse?: () => void) => } /> @@ -71,6 +83,14 @@ describe('ExploreRail', () => { expect(screen.getByRole('link', { name: /security/ })).toBeInTheDocument(); }); + it('tells a filter matching nothing apart from a genuinely empty namespace list', async () => { + renderRail(); + await screen.findByRole('link', { name: /finos/ }); + + fireEvent.change(screen.getByLabelText('Filter namespaces'), { target: { value: 'no-such-namespace' } }); + expect(screen.getByText('No namespaces match your filter')).toBeInTheDocument(); + }); + it('marks the namespace row matching the URL as active', async () => { renderRail('/namespace/traderx'); const active = await screen.findByRole('link', { name: /traderx/ }); @@ -86,9 +106,46 @@ describe('ExploreRail', () => { it('invokes onCollapse when the collapse button is clicked', async () => { const onCollapse = vi.fn(); - renderRail('/', onCollapse); + renderRail('/', { onCollapse }); fireEvent.click(screen.getByLabelText('Collapse sidebar')); expect(onCollapse).toHaveBeenCalled(); await screen.findByRole('link', { name: /finos/ }); }); + + it('shows a spinner in both sections while both are loading', () => { + renderRail('/', { namespacesLoading: true, domainsLoading: true }); + expect(screen.getAllByRole('status')).toHaveLength(2); + expect(screen.queryByRole('link', { name: /finos/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /security/ })).not.toBeInTheDocument(); + }); + + it('resolves the namespaces section independently of a still-loading domains section', async () => { + renderRail('/', { namespacesLoading: false, domainsLoading: true }); + expect(await screen.findByRole('link', { name: /finos/ })).toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Loading control domains' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /security/ })).not.toBeInTheDocument(); + }); + + it('resolves the domains section independently of a still-loading namespaces section', async () => { + renderRail('/', { namespacesLoading: true, domainsLoading: false }); + expect(await screen.findByRole('link', { name: /security/ })).toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Loading namespaces' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /finos/ })).not.toBeInTheDocument(); + }); + + it('shows items instead of spinners once both sections finish loading', async () => { + renderRail('/', { namespacesLoading: false, domainsLoading: false }); + expect(await screen.findByRole('link', { name: /finos/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /security/ })).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows a distinct message when a counts fetch fails, rather than an ambiguous empty state', async () => { + renderRail('/', { namespacesLoading: false, domainsLoading: false, namespacesFailed: true, domainsFailed: true }); + // A failed fetch is "unknown", not "confirmed zero" — the empty-state text + // must say so rather than looking identical to a genuinely empty namespace. + expect(await screen.findByText("Couldn't load namespaces")).toBeInTheDocument(); + expect(screen.getByText("Couldn't load control domains")).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /finos/ })).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 800333aa51..75d029aa12 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 { useMemo, useState } from 'react'; +import { ReactNode, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { IoCompassOutline, IoChevronBackOutline } from 'react-icons/io5'; import { NamespaceCounts, DomainControlCount } from '../../../model/counts.js'; @@ -6,16 +6,37 @@ import { colors } from '../../../theme/colors.js'; import { redesignTokens } from '../../../theme/redesign-tokens.js'; import { RailItem } from './RailItem.js'; import { RailSectionLabel } from './RailSectionLabel.js'; +import { LoadingSpinner } from '../LoadingSpinner.js'; interface ExploreRailProps { /** Per-namespace counts, fetched once by {@link Hub} and passed down. */ namespaceCounts: NamespaceCounts[]; /** Per-domain control counts, fetched once by {@link Hub} and passed down. */ domainCounts: DomainControlCount[]; + /** True while the namespace counts are still being fetched. */ + namespacesLoading?: boolean; + /** True while the domain control counts are still being fetched. */ + domainsLoading?: boolean; + /** True if the namespace counts fetch failed — distinct from "loaded and empty". */ + namespacesFailed?: boolean; + /** True if the domain counts fetch failed — distinct from "loaded and empty". */ + domainsFailed?: boolean; /** Collapse the rail (keeps the existing sidebar collapse affordance). */ onCollapse?: () => void; } +function RailSpinner({ label }: { label: string }) { + return ( +
    + +
    + ); +} + +function RailEmpty({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + type RailRouteParams = { ns?: string; domain?: string; namespace?: string }; /** @@ -28,7 +49,15 @@ type RailRouteParams = { ns?: string; domain?: string; namespace?: string }; * once there and shared), so this component takes them as props rather than * re-fetching them itself. */ -export function ExploreRail({ namespaceCounts, domainCounts, onCollapse }: ExploreRailProps) { +export function ExploreRail({ + namespaceCounts, + domainCounts, + namespacesLoading, + domainsLoading, + namespacesFailed, + domainsFailed, + onCollapse, +}: 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. const { ns, domain: activeDomain, namespace } = useParams(); @@ -82,28 +111,46 @@ export function ExploreRail({ namespaceCounts, domainCounts, onCollapse }: Explo
    NAMESPACES
    - {filteredNamespaces.map((nc) => ( - - ))} + {namespacesLoading ? ( + + ) : namespacesFailed ? ( + Couldn't load namespaces + ) : filteredNamespaces.length === 0 ? ( + + {namespaceCounts.length === 0 ? 'Nothing here' : 'No namespaces match your filter'} + + ) : ( + filteredNamespaces.map((nc) => ( + + )) + )}
    CONTROL DOMAINS
    - {domainCounts.map((dc) => ( - - ))} + {domainsLoading ? ( + + ) : domainsFailed ? ( + Couldn't load control domains + ) : domainCounts.length === 0 ? ( + Nothing here + ) : ( + domainCounts.map((dc) => ( + + )) + )}
    diff --git a/calm-hub-ui/src/hub/components/section-header/SectionHeader.test.tsx b/calm-hub-ui/src/hub/components/section-header/SectionHeader.test.tsx index 40d6326dda..aaaedd6181 100644 --- a/calm-hub-ui/src/hub/components/section-header/SectionHeader.test.tsx +++ b/calm-hub-ui/src/hub/components/section-header/SectionHeader.test.tsx @@ -1,9 +1,15 @@ +import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { SectionHeader } from './SectionHeader.js'; +import { MemoryRouter } from 'react-router-dom'; +import { SectionHeader as SectionHeaderRaw } from './SectionHeader.js'; import { describe, it, expect, vi } from 'vitest'; import type { BreadcrumbItem } from '../../../model/calm.js'; +function SectionHeader(props: React.ComponentProps) { + return ; +} + describe('SectionHeader', () => { it('renders icon, namespace, id, and version', () => { const icon = Icon; @@ -47,6 +53,50 @@ describe('SectionHeader', () => { expect(heading).not.toHaveTextContent('42'); }); + it('links the namespace and type label to the correct namespace/filtered-type routes', () => { + render( + Icon} + namespace="my-namespace" + id="42" + version="1.0.0" + typeSegment="architectures" + typeLabel="Architecture" + /> + ); + + expect(screen.getByRole('link', { name: 'my-namespace' })).toHaveAttribute( + 'href', + '/namespace/my-namespace' + ); + expect(screen.getByRole('link', { name: 'Architecture' })).toHaveAttribute( + 'href', + '/namespace/my-namespace?type=architectures' + ); + }); + + it('encodes namespace and type segments containing reserved URL characters', () => { + render( + Icon} + namespace="my namespace" + id="42" + version="1.0.0" + typeSegment="building blocks" + typeLabel="Building Block" + /> + ); + + expect(screen.getByRole('link', { name: 'my namespace' })).toHaveAttribute( + 'href', + '/namespace/my%20namespace' + ); + expect(screen.getByRole('link', { name: 'Building Block' })).toHaveAttribute( + 'href', + '/namespace/my%20namespace?type=building%20blocks' + ); + }); + it('renders right content when provided', () => { const icon = Icon; const rightContent =
    Right Content
    ; diff --git a/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx b/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx index b08325b7a5..3442bde10a 100644 --- a/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx +++ b/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx @@ -1,4 +1,5 @@ import { ReactNode, useState } from 'react'; +import { Link } from 'react-router-dom'; import { IoCopyOutline, IoCheckmarkOutline, IoLinkOutline } from 'react-icons/io5'; import { BreadcrumbItem, isSlug } from '../../../model/calm.js'; import { BreadcrumbTrail } from './BreadcrumbTrail.js'; @@ -45,11 +46,12 @@ export function SectionHeader({ icon, namespace, id, version, typeSegment, right

    {icon} {breadcrumbs && } - {namespace} + {namespace} {typeLabel && ( <> {' '} - / {typeLabel} + /{' '} + {typeLabel} )}{' '} /{' '} 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 518362bb62..6b3635c9d2 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 @@ -185,4 +185,60 @@ describe('MobileNavMenu', () => { expect(await screen.findByText('traderx')).toBeInTheDocument(); expect(screen.queryByText('Architectures')).not.toBeInTheDocument(); }); + + it('shows the static root rows immediately, even while counts are still loading', () => { + render( + + + + ); + // The root rows are static labels, not derived from counts, so they must + // never be hidden behind a counts spinner. + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByText('Namespaces')).toBeInTheDocument(); + expect(screen.getByText('Control Domains')).toBeInTheDocument(); + }); + + it('shows a spinner only for the section whose own counts are still loading', async () => { + render( + + + + ); + // A single OR'd flag couldn't tell these two cases apart. + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('traderx')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Back')); + fireEvent.click(screen.getByText('Control Domains')); + // Section-specific, matching ExploreRail's equivalent spinner labels — + // not a bare "Loading" that doesn't say which section to a screen reader. + expect(screen.getByRole('status', { name: 'Loading control domains' })).toBeInTheDocument(); + expect(screen.queryByText('security')).not.toBeInTheDocument(); + }); + + it('shows a distinct message when a counts fetch fails, rather than an ambiguous empty state', async () => { + // Hub clears counts to [] on a failed fetch, so the failure looks + // identical to a genuinely empty namespace/domain list unless the + // *Failed flag is threaded through to distinguish "unknown" from "zero". + render( + + + + ); + fireEvent.click(screen.getByText('Namespaces')); + // No retry action exists here, so the copy must not promise one. + expect(await screen.findByText("Couldn't load namespaces")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Back')); + fireEvent.click(screen.getByText('Control Domains')); + expect(await screen.findByText("Couldn't load control domains")).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 821dbbec26..6e47362d29 100644 --- a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx @@ -19,6 +19,7 @@ import { fetchVersionsForResource, } from './navigation-loaders.js'; import { ExplorerSearch } from '../../../components/navbar/ExplorerSearch.js'; +import { LoadingSpinner } from '../LoadingSpinner.js'; const RESOURCE_TYPES: TypeInUI[] = ['Architectures', 'Patterns', 'Flows', 'Standards', 'ADRs', 'Interfaces']; @@ -27,6 +28,14 @@ interface MobileNavMenuProps { namespaceCounts: NamespaceCounts[]; /** Per-domain control counts, fetched once by {@link Hub} and passed down. */ domainCounts: DomainControlCount[]; + /** True while the namespace counts are still being fetched. */ + namespacesLoading?: boolean; + /** True while the domain control counts are still being fetched. */ + domainsLoading?: boolean; + /** True if the namespace counts fetch failed — distinct from "loaded and empty". */ + namespacesFailed?: boolean; + /** True if the domain counts fetch failed — distinct from "loaded and empty". */ + domainsFailed?: boolean; /** Dismiss the menu (e.g. after a resource is chosen). */ onClose: () => void; } @@ -66,7 +75,15 @@ interface LeafItem { * {@link Hub} (fetched once and shared) and passed in as props rather than * re-fetched here. */ -export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: MobileNavMenuProps) { +export function MobileNavMenu({ + namespaceCounts, + domainCounts, + namespacesLoading, + domainsLoading, + namespacesFailed, + domainsFailed, + onClose, +}: MobileNavMenuProps) { const navigate = useNavigate(); const params = useParams(); @@ -77,7 +94,7 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile const [view, setView] = useState({ level: 'root' }); const [leafItems, setLeafItems] = useState([]); - const [loading, setLoading] = useState(false); + const [leafLoading, setLeafLoading] = useState(false); const [searching, setSearching] = useState(false); // Derive the namespace/domain lists from the counts Hub already fetched, rather than @@ -111,10 +128,10 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile (namespace: string, type: TypeInUI) => { setView({ level: 'resources', namespace, type }); setLeafItems([]); - setLoading(true); + setLeafLoading(true); const finish = (items: LeafItem[]) => { setLeafItems(items); - setLoading(false); + setLeafLoading(false); }; if (type === 'Interfaces') { interfaceService @@ -149,14 +166,14 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile (domain: string) => { setView({ level: 'controls', domain }); setLeafItems([]); - setLoading(true); + setLeafLoading(true); controlService .fetchControlsForDomain(domain) .then((controls: ControlDetail[]) => setLeafItems(controls.map((c) => ({ id: c.id.toString(), name: c.title ?? c.name }))) ) .catch(() => setLeafItems([])) - .finally(() => setLoading(false)); + .finally(() => setLeafLoading(false)); }, [controlService] ); @@ -287,7 +304,28 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile } })(); - const isEmpty = !loading && rows.length === 0; + // The root rows ('Namespaces', 'Control Domains') are static labels, not + // count-derived, so they render immediately — only the level whose data is + // actually in flight shows a spinner. + const showLoading = + leafLoading || + (view.level === 'namespaces' && namespacesLoading) || + (view.level === 'domains' && domainsLoading); + // Matches ExploreRail's section-specific spinner labels, rather than a bare + // "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; + // 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 + // must not promise one — matches ExploreRail's equivalent desktop wording. + const emptyMessage = + view.level === 'namespaces' && namespacesFailed + ? "Couldn't load namespaces" + : view.level === 'domains' && domainsFailed + ? "Couldn't load control domains" + : 'Nothing here'; return (
    @@ -309,15 +347,15 @@ export function MobileNavMenu({ namespaceCounts, domainCounts, onClose }: Mobile {!searching && (
      - {loading && ( + {showLoading && (
    • - +
    • )} {isEmpty && ( -
    • Nothing here
    • +
    • {emptyMessage}
    • )} - {!loading && + {!showLoading && rows.map((row) => (