Skip to content

Commit 75fcdbf

Browse files
authored
feat(calm-hub-ui): adding detailed architecture navigation (finos#2799)
* feat(calm-hub-ui): adding detailed architecture navigation * feat(calm-hub-ui): adding resource type to not found error message * fix(calm-hub-ui): don't advertise node drill-down for unresolvable detailed-architecture refs * fix(calm-hub-ui): guard success callbacks against stale loads after navigation * fix(calm-hub-ui): widen the immediate-parent breadcrumb cap to 12rem
1 parent 1f1d52e commit 75fcdbf

24 files changed

Lines changed: 1761 additions & 103 deletions

calm-hub-ui/src/hub/Hub.test.tsx

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { render, screen, fireEvent, act } from '@testing-library/react';
3-
import { MemoryRouter, useNavigate } from 'react-router-dom';
3+
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
44
import Hub from './Hub.js';
55
import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest';
66
import { authStore } from '../service/utils/auth-store.js';
@@ -131,10 +131,44 @@ vi.mock('../components/navbar/Navbar', () => ({
131131
Navbar: () => <nav data-testid="navbar">Navbar</nav>,
132132
}));
133133

134-
vi.mock('./components/diagram-section/DiagramSection', () => ({
135-
DiagramSection: ({ data, onItemSelect }: { data: { id?: string }; onItemSelect?: (item: { data: unknown }) => void }) => (
134+
vi.mock('./components/diagram-section/DiagramSection', async () => {
135+
// Real context, not a mock: the stub consumes Hub's provider exactly like the
136+
// production tree (CustomNode / NodeDetails) does.
137+
const { useDiagramActions } = await import('../visualizer/context/DiagramActionsContext.js');
138+
const DiagramSection = ({
139+
data,
140+
onItemSelect,
141+
breadcrumbs,
142+
onDisplayNameChange,
143+
}: {
144+
data: { id?: string };
145+
onItemSelect?: (item: { data: unknown }) => void;
146+
breadcrumbs?: unknown[];
147+
onDisplayNameChange?: (name: string | undefined) => void;
148+
}) => {
149+
const { onNavigateToDetailedArch } = useDiagramActions();
150+
return (
136151
<div data-testid="diagram-section">
137152
Diagram: {data?.id}
153+
{/* Trail from history state, echoed for breadcrumb-accumulation tests. */}
154+
<div data-testid="breadcrumbs">{JSON.stringify(breadcrumbs ?? [])}</div>
155+
{/* Simulates the summaries fetch resolving the display name. */}
156+
<button data-testid="set-display-name" onClick={() => onDisplayNameChange?.('Nice Name')}>
157+
set display name
158+
</button>
159+
{/* Simulates a node's "Explore Architecture" action (context consumer). */}
160+
<button
161+
data-testid="navigate-detailed-arch"
162+
onClick={() => onNavigateToDetailedArch?.('/calm/namespaces/finos/architectures/target-arch/versions/2.0.0')}
163+
>
164+
navigate detailed
165+
</button>
166+
<button
167+
data-testid="navigate-detailed-arch-bad"
168+
onClick={() => onNavigateToDetailedArch?.('/not/a/calm/path')}
169+
>
170+
navigate bad
171+
</button>
138172
{/* Lets tests simulate a node/edge tap on the graph so Hub's node-detail
139173
wiring (the mobile bottom-sheet + prev/next steppers) is exercised.
140174
`select-node` (n1) is retained for existing tests; `select-n1/n2/n3`
@@ -197,8 +231,10 @@ vi.mock('./components/diagram-section/DiagramSection', () => ({
197231
select edge
198232
</button>
199233
</div>
200-
),
201-
}));
234+
);
235+
};
236+
return { DiagramSection };
237+
});
202238

203239
// Helpers to drive the captured load callbacks.
204240
const loadData = () =>
@@ -278,6 +314,21 @@ const renderAt = (path: string) =>
278314
</MemoryRouter>
279315
);
280316

317+
// Echoes the live pathname so tests can assert where a context-triggered
318+
// navigation landed. Accepts entry objects so a test can seed history state.
319+
function LocationProbe() {
320+
const location = useLocation();
321+
return <div data-testid="location-pathname">{location.pathname}</div>;
322+
}
323+
324+
const renderWithLocation = (entries: React.ComponentProps<typeof MemoryRouter>['initialEntries']) =>
325+
render(
326+
<MemoryRouter initialEntries={entries}>
327+
<Hub />
328+
<LocationProbe />
329+
</MemoryRouter>
330+
);
331+
281332
// A tiny in-router harness so tests can drive real react-router navigation
282333
// (changing location.key) against the same <Hub/> instance — `rerender` with a
283334
// fresh MemoryRouter would remount Hub and discard its state, defeating the test.
@@ -722,4 +773,69 @@ describe('Hub', () => {
722773
expect(screen.queryByTestId('adr-renderer')).not.toBeInTheDocument();
723774
});
724775
});
776+
describe('detailed-architecture navigation (breadcrumb state)', () => {
777+
const readCrumbs = () => JSON.parse(screen.getByTestId('breadcrumbs').textContent!);
778+
779+
it('pushes the current resource as a named crumb and navigates to the parsed target', () => {
780+
renderWithLocation(['/test-namespace/architectures/arch/1.0']);
781+
loadArchitectureWithNodes();
782+
fireEvent.click(screen.getByTestId('set-display-name'));
783+
784+
fireEvent.click(screen.getByTestId('navigate-detailed-arch'));
785+
786+
expect(screen.getByTestId('location-pathname')).toHaveTextContent(
787+
'/finos/architectures/target-arch/2.0.0'
788+
);
789+
// The new route's resource loads; the remounted diagram receives the trail.
790+
loadArchitectureWithNodes();
791+
expect(readCrumbs()).toEqual([
792+
{ namespace: 'test-namespace', type: 'architectures', id: 'arch', version: '1.0', name: 'Nice Name' },
793+
]);
794+
});
795+
796+
it('accumulates one crumb per hop and omits the name when it never resolved', () => {
797+
renderWithLocation(['/test-namespace/architectures/arch/1.0']);
798+
loadArchitectureWithNodes();
799+
fireEvent.click(screen.getByTestId('navigate-detailed-arch'));
800+
loadArchitectureWithNodes();
801+
expect(readCrumbs()).toHaveLength(1);
802+
803+
fireEvent.click(screen.getByTestId('navigate-detailed-arch'));
804+
loadArchitectureWithNodes();
805+
806+
const crumbs = readCrumbs();
807+
expect(crumbs).toHaveLength(2);
808+
// No set-display-name click before either hop's navigation: the crumb
809+
// carries no name and consumers fall back to the id.
810+
expect(crumbs[0].name).toBeUndefined();
811+
expect(crumbs[1]).toEqual({ namespace: 'test-namespace', type: 'architectures', id: 'arch', version: '1.0' });
812+
});
813+
814+
it('ignores references that are not CALM Hub paths, keeping the current view intact', () => {
815+
renderWithLocation(['/test-namespace/architectures/arch/1.0']);
816+
loadArchitectureWithNodes();
817+
818+
fireEvent.click(screen.getByTestId('navigate-detailed-arch-bad'));
819+
820+
expect(screen.getByTestId('location-pathname')).toHaveTextContent(
821+
'/test-namespace/architectures/arch/1.0'
822+
);
823+
// No navigation happened, so the loaded diagram was not cleared.
824+
expect(screen.getByTestId('diagram-section')).toHaveTextContent('Diagram: arch');
825+
});
826+
827+
it('filters malformed entries out of untrusted history state', () => {
828+
const valid = { namespace: 'finos', type: 'architectures', id: 'parent', version: '1.0.0' };
829+
renderWithLocation([
830+
{
831+
pathname: '/test-namespace/architectures/arch/1.0',
832+
state: { breadcrumbs: [valid, { junk: true }, 'nonsense', null, { namespace: 'x', type: 'flows', id: 'y', version: '1' }] },
833+
},
834+
]);
835+
loadArchitectureWithNodes();
836+
837+
expect(readCrumbs()).toEqual([valid]);
838+
});
839+
});
840+
725841
});

calm-hub-ui/src/hub/Hub.tsx

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
1+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
22
import { useLocation, useMatch, useNavigate } from 'react-router-dom';
33
import { IoChevronForwardOutline, IoCompassOutline } from 'react-icons/io5';
44
import { ExploreRail } from './components/explore-rail/ExploreRail.js';
@@ -8,7 +8,7 @@ import { DomainPage } from './components/domain-page/DomainPage.js';
88
import { FirstRunLanding } from './components/first-run-landing/FirstRunLanding.js';
99
import { useResourceFromRoute } from './hooks/useResourceFromRoute.js';
1010
import { useIsMobile } from '../hooks/useMediaQuery.js';
11-
import { Data, Adr } from '../model/calm.js';
11+
import { BreadcrumbItem, Data, Adr } from '../model/calm.js';
1212
import { ControlData } from '../model/control.js';
1313
import { InterfaceData } from '../model/interface.js';
1414
import { NamespaceCounts, DomainControlCount } from '../model/counts.js';
@@ -21,19 +21,46 @@ import { InterfaceDetailSection } from './components/interface-detail-section/In
2121
import { DiagramSection } from './components/diagram-section/DiagramSection.js';
2222
import { Sidebar } from '../visualizer/components/sidebar/Sidebar.js';
2323
import { NodeSheet } from '../visualizer/components/sidebar/NodeSheet.js';
24+
import { DiagramActionsContext } from '../visualizer/context/DiagramActionsContext.js';
25+
import { ResourceNotFound } from './components/resource-not-found/ResourceNotFound.js';
26+
import { parseCALMHubPath } from '../visualizer/components/reactflow/utils/calmHelpers.js';
2427
import type { SelectedItem } from '../visualizer/contracts/contracts.js';
2528
import type { CalmNodeSchema } from '@finos/calm-models/types';
2629
import { authStore } from '../service/utils/auth-store.js';
2730
import './Hub.css';
2831

32+
// Breadcrumbs live in history state so Back restores the parent's trail for free,
33+
// at the documented cost that a hard refresh or shared deep link starts with an
34+
// empty trail. History state is untyped and can be stale (older sessions) or set
35+
// by other code, so validate the shape instead of trusting a cast.
36+
function readBreadcrumbs(state: unknown): BreadcrumbItem[] {
37+
const crumbs = (state as { breadcrumbs?: unknown } | null)?.breadcrumbs;
38+
if (!Array.isArray(crumbs)) return [];
39+
return crumbs.filter(
40+
(c): c is BreadcrumbItem =>
41+
typeof c === 'object' &&
42+
c !== null &&
43+
typeof (c as BreadcrumbItem).namespace === 'string' &&
44+
((c as BreadcrumbItem).type === 'architectures' || (c as BreadcrumbItem).type === 'patterns') &&
45+
typeof (c as BreadcrumbItem).id === 'string' &&
46+
typeof (c as BreadcrumbItem).version === 'string'
47+
);
48+
}
49+
2950
export default function Hub() {
51+
const navigate = useNavigate();
52+
const location = useLocation();
53+
const currentDisplayNameRef = useRef<string | undefined>(undefined);
3054
const [data, setData] = useState<Data | undefined>();
3155
const [adrData, setAdrData] = useState<Adr | undefined>();
3256
const [controlData, setControlData] = useState<ControlData | undefined>();
3357
const [interfaceData, setInterfaceData] = useState<InterfaceData | undefined>();
3458
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
3559
const [isMobileNavOpen, setIsMobileNavOpen] = useState(true);
3660
const [selectedItem, setSelectedItem] = useState<SelectedItem>(null);
61+
// A detail-route load rejected (missing resource, dangling reference). Cleared
62+
// on every navigation; renders ResourceNotFound instead of an empty pane.
63+
const [resourceLoadFailed, setResourceLoadFailed] = useState(false);
3764
const [namespaceCounts, setNamespaceCounts] = useState<NamespaceCounts[]>([]);
3865
const [namespaceCountsLoaded, setNamespaceCountsLoaded] = useState(false);
3966
// Distinct from "loaded": a failed counts fetch means counts are unknown, not
@@ -46,8 +73,6 @@ export default function Hub() {
4673
// Route-first content selection (redesign problem #4): the same <Hub/> element
4774
// is reused across `/`, `/namespace/:ns`, `/domain/:domain` and the detail
4875
// route, so the URL — not residual state — decides what renders.
49-
const { key: locationKey } = useLocation();
50-
const navigate = useNavigate();
5176
const namespaceMatch = useMatch('/namespace/:ns');
5277
const domainMatch = useMatch('/domain/:domain');
5378
const detailMatch = useMatch('/:namespace/:type/:id/:version');
@@ -107,7 +132,8 @@ export default function Hub() {
107132
setControlData(undefined);
108133
setInterfaceData(undefined);
109134
setSelectedItem(null);
110-
}, [locationKey]);
135+
setResourceLoadFailed(false);
136+
}, [location.key]);
111137

112138
const handleDataLoad = useCallback((loaded: Data) => {
113139
setData(loaded);
@@ -116,6 +142,7 @@ export default function Hub() {
116142
setInterfaceData(undefined);
117143
setSelectedItem(null);
118144
setIsMobileNavOpen(false);
145+
currentDisplayNameRef.current = undefined;
119146
}, []);
120147

121148
const handleAdrLoad = useCallback((adr: Adr) => {
@@ -145,12 +172,18 @@ export default function Hub() {
145172
setIsMobileNavOpen(false);
146173
}, []);
147174

175+
const handleResourceLoadError = useCallback((error: unknown) => {
176+
console.error('%s', 'Failed to load routed resource:', error);
177+
setResourceLoadFailed(true);
178+
}, []);
179+
148180
// Single owner of deep-link / external-navigation loading for the detail route.
149181
useResourceFromRoute({
150182
onDataLoad: handleDataLoad,
151183
onAdrLoad: handleAdrLoad,
152184
onControlLoad: handleControlLoad,
153185
onInterfaceLoad: handleInterfaceLoad,
186+
onLoadError: handleResourceLoadError,
154187
});
155188

156189
const handleItemSelect = useCallback((item: SelectedItem) => {
@@ -192,6 +225,41 @@ export default function Hub() {
192225
[isDetailRoute, navigate, handleControlLoad]
193226
);
194227

228+
// The resource's display name is fetched by DiagramSection (it owns the
229+
// summaries fetch) and mirrored here so the crumb pushed on navigation can
230+
// carry it. A ref, not state: it is only read imperatively at navigate time,
231+
// so Hub need not re-render when it resolves. This handler MUST stay memoised
232+
// (identity-stable) — DiagramSection lists it in a fetch effect's deps.
233+
const handleDisplayNameChange = useCallback((name: string | undefined) => {
234+
currentDisplayNameRef.current = name;
235+
}, []);
236+
237+
const handleNavigateToDetailedArch = useCallback((ref: string) => {
238+
const parsed = parseCALMHubPath(ref);
239+
if (!parsed || !data) return;
240+
const currentCrumb: BreadcrumbItem = {
241+
namespace: data.name,
242+
type: data.calmType === 'Architectures' ? 'architectures' : 'patterns',
243+
id: data.id,
244+
version: data.version,
245+
name: currentDisplayNameRef.current,
246+
};
247+
navigate(`/${parsed.namespace}/${parsed.type}/${parsed.id}/${parsed.version}`, {
248+
state: { breadcrumbs: [...readBreadcrumbs(location.state), currentCrumb] }
249+
});
250+
}, [navigate, data, location.state]);
251+
252+
const breadcrumbs = useMemo(() => readBreadcrumbs(location.state), [location.state]);
253+
254+
// Single, memoised provider value for every detailed-architecture navigation
255+
// consumer: CustomNode (which ReactFlow instantiates internally, out of reach
256+
// of props) and NodeDetails (rendered in both the Sidebar and the NodeSheet).
257+
// Memoised so consumers don't re-render on unrelated Hub renders.
258+
const diagramActions = useMemo(
259+
() => ({ onNavigateToDetailedArch: handleNavigateToDetailedArch }),
260+
[handleNavigateToDetailedArch]
261+
);
262+
195263
const isDiagramView = data?.calmType === 'Architectures' || data?.calmType === 'Patterns';
196264

197265
// Mobile node bottom-sheet prev/next steppers (Frame G). The ordered node list
@@ -269,7 +337,13 @@ export default function Hub() {
269337
) : adrData ? (
270338
<AdrRenderer adrDetails={adrData} />
271339
) : isDiagramView ? (
272-
<DiagramSection data={data} onItemSelect={handleItemSelect} hasDetailsPanel={!!selectedItem} />
340+
<DiagramSection
341+
data={data}
342+
onItemSelect={handleItemSelect}
343+
hasDetailsPanel={!!selectedItem}
344+
breadcrumbs={breadcrumbs}
345+
onDisplayNameChange={handleDisplayNameChange}
346+
/>
273347
) : (
274348
<DocumentDetailSection data={data} />
275349
);
@@ -291,6 +365,16 @@ export default function Hub() {
291365
onControlLoad={handleControlActivate}
292366
selectedControlId={controlData.controlId}
293367
/>
368+
) : isDetailRoute && resourceLoadFailed && !interfaceData && !adrData && !data ? (
369+
// The routed resource failed to load: show a recoverable not-found state
370+
// (never a blank pane). Loaded data wins if a late success ever lands.
371+
<ResourceNotFound
372+
namespace={detailMatch.params.namespace!}
373+
id={detailMatch.params.id!}
374+
version={detailMatch.params.version!}
375+
type={detailMatch.params.type}
376+
breadcrumbs={breadcrumbs}
377+
/>
294378
) : isDetailRoute || interfaceData || adrData || data ? (
295379
detailContent
296380
) : activeNamespace ? (
@@ -313,6 +397,7 @@ export default function Hub() {
313397
);
314398

315399
return (
400+
<DiagramActionsContext.Provider value={diagramActions}>
316401
<div className="flex flex-col h-screen overflow-hidden">
317402
<Navbar />
318403
{isMobile && !isMobileNavOpen && (
@@ -415,5 +500,6 @@ export default function Hub() {
415500
)}
416501
</div>
417502
</div>
503+
</DiagramActionsContext.Provider>
418504
);
419505
}

0 commit comments

Comments
 (0)