Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions calm-hub-ui/src/hub/Hub.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 27 additions & 8 deletions calm-hub-ui/src/hub/Hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export default function Hub() {
const [namespaceCountsFailed, setNamespaceCountsFailed] = useState(false);
const [domainCounts, setDomainCounts] = useState<DomainControlCount[]>([]);
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 <Hub/> element
Expand Down Expand Up @@ -112,7 +114,10 @@ export default function Hub() {
countsService
.fetchDomainCounts()
.then(setDomainCounts)
.catch(() => setDomainCounts([]))
.catch(() => {
setDomainCounts([]);
setDomainCountsFailed(true);
})
.finally(() => setDomainCountsLoaded(true));
}, [countsService]);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -465,6 +476,10 @@ export default function Hub() {
<ExploreRail
namespaceCounts={namespaceCounts}
domainCounts={domainCounts}
namespacesLoading={!namespaceCountsLoaded}
domainsLoading={!domainCountsLoaded}
namespacesFailed={namespaceCountsFailed}
domainsFailed={domainCountsFailed}
onCollapse={() => setIsSidebarOpen(false)}
/>
) : (
Expand Down Expand Up @@ -498,6 +513,10 @@ export default function Hub() {
<MobileNavMenu
namespaceCounts={namespaceCounts}
domainCounts={domainCounts}
namespacesLoading={!namespaceCountsLoaded}
domainsLoading={!domainCountsLoaded}
namespacesFailed={namespaceCountsFailed}
domainsFailed={domainCountsFailed}
onClose={() => setIsMobileNavOpen(false)}
/>
</div>
Expand Down
9 changes: 9 additions & 0 deletions calm-hub-ui/src/hub/components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -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 `<div>` vs a mobile `<li>`), since those differ by context —
* only the spinner itself needs to stay identical between them.
*/
export function LoadingSpinner({ label }: { label: string }) {
return <span role="status" aria-label={label} className="loading loading-spinner loading-md text-base-content/50" />;
}
Loading
Loading