From 031f9383166e5b417510d1399a0414e25825594d Mon Sep 17 00:00:00 2001 From: Matthew Bain Date: Sat, 20 Jun 2026 12:33:53 +0000 Subject: [PATCH 01/17] feat(calm-hub-ui): make Hub and Visualizer layouts mobile responsive The Hub used a rigid three-column flex layout (tree navigation, content, details panel) with fixed/percentage widths that could not fit on phones or tablet-portrait viewports. - Add a useMediaQuery/useIsMobile hook (test-safe; guards window.matchMedia) - Hub: below the lg breakpoint the tree navigation becomes an off-canvas drawer with a dimmed backdrop, toggled by an "Explore" button; the details Sidebar becomes a full-screen overlay. Desktop behaviour is unchanged. - Sidebar (details panel): responsive width (w-full on mobile, w-96 on lg+) - GlobalSearchBar: responsive input width and viewport-bounded dropdown - SectionHeader: allow the breadcrumb and tabs to wrap instead of overflowing - Compare view: stack the two diff graphs vertically on narrow viewports - Add matchMedia polyfill to the vitest setup and tests for the new hook and the Hub mobile drawer behaviour Signed-off-by: Matthew Bain --- .../src/components/navbar/GlobalSearchBar.tsx | 4 +- calm-hub-ui/src/diff/Diff.css | 17 +++ calm-hub-ui/src/hooks/useMediaQuery.test.ts | 78 +++++++++++ calm-hub-ui/src/hooks/useMediaQuery.ts | 42 ++++++ calm-hub-ui/src/hub/Hub.test.tsx | 74 ++++++++++- calm-hub-ui/src/hub/Hub.tsx | 125 +++++++++++++----- .../section-header/SectionHeader.tsx | 6 +- .../visualizer/components/sidebar/Sidebar.tsx | 2 +- calm-hub-ui/vitest.setup.ts | 17 +++ 9 files changed, 327 insertions(+), 38 deletions(-) create mode 100644 calm-hub-ui/src/hooks/useMediaQuery.test.ts create mode 100644 calm-hub-ui/src/hooks/useMediaQuery.ts diff --git a/calm-hub-ui/src/components/navbar/GlobalSearchBar.tsx b/calm-hub-ui/src/components/navbar/GlobalSearchBar.tsx index 1b14d4403..ead98b59a 100644 --- a/calm-hub-ui/src/components/navbar/GlobalSearchBar.tsx +++ b/calm-hub-ui/src/components/navbar/GlobalSearchBar.tsx @@ -289,7 +289,7 @@ export function GlobalSearchBar({ searchService, calmService: calmServiceProp, a ref={inputRef} type="text" placeholder="Search CALM Hub..." - className="bg-transparent border-none outline-none text-sm text-base-content placeholder:text-base-content/40 w-48 lg:w-64" + className="bg-transparent border-none outline-none text-sm text-base-content placeholder:text-base-content/40 w-28 sm:w-48 lg:w-64" value={query} onChange={handleInputChange} onKeyDown={handleKeyDown} @@ -313,7 +313,7 @@ export function GlobalSearchBar({ searchService, calmService: calmServiceProp, a {showDropdown && (
{renderGroupedResults()} diff --git a/calm-hub-ui/src/diff/Diff.css b/calm-hub-ui/src/diff/Diff.css index 20ac5aac2..f36be467e 100644 --- a/calm-hub-ui/src/diff/Diff.css +++ b/calm-hub-ui/src/diff/Diff.css @@ -65,6 +65,23 @@ border-right: none; } +/* Stack the two comparison graphs vertically on narrow viewports so each one + * has enough room to be legible instead of being squeezed side by side. */ +@media (width <= 1023px) { + .architectures-container { + flex-direction: column; + } + + .architecture-panel { + border-right: none; + border-bottom: 1px solid #e5e7eb; + } + + .architecture-panel:last-child { + border-bottom: none; + } +} + .architecture-header { padding: 12px 16px; background: #f9fafb; diff --git a/calm-hub-ui/src/hooks/useMediaQuery.test.ts b/calm-hub-ui/src/hooks/useMediaQuery.test.ts new file mode 100644 index 000000000..85a0005a9 --- /dev/null +++ b/calm-hub-ui/src/hooks/useMediaQuery.test.ts @@ -0,0 +1,78 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useMediaQuery, useIsMobile } from './useMediaQuery.js'; + +type Listener = () => void; + +/** + * Install a controllable matchMedia mock and return a helper to flip the + * match state and notify subscribers. + */ +function installMatchMedia(initialMatches: boolean) { + let matches = initialMatches; + const listeners = new Set(); + + const matchMedia = vi.fn((query: string) => ({ + get matches() { + return matches; + }, + media: query, + onchange: null, + addEventListener: (_: string, cb: Listener) => listeners.add(cb), + removeEventListener: (_: string, cb: Listener) => listeners.delete(cb), + addListener: (cb: Listener) => listeners.add(cb), + removeListener: (cb: Listener) => listeners.delete(cb), + dispatchEvent: () => false, + })); + + window.matchMedia = matchMedia as unknown as typeof window.matchMedia; + + return { + setMatches(next: boolean) { + matches = next; + listeners.forEach((cb) => cb()); + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('useMediaQuery', () => { + it('returns the initial match state', () => { + installMatchMedia(true); + const { result } = renderHook(() => useMediaQuery('(max-width: 1023px)')); + expect(result.current).toBe(true); + }); + + it('updates when the media query changes', () => { + const mm = installMatchMedia(false); + const { result } = renderHook(() => useMediaQuery('(max-width: 1023px)')); + expect(result.current).toBe(false); + + act(() => mm.setMatches(true)); + expect(result.current).toBe(true); + }); + + it('returns false when matchMedia is unavailable', () => { + // @ts-expect-error - simulate an environment without matchMedia + window.matchMedia = undefined; + const { result } = renderHook(() => useMediaQuery('(max-width: 1023px)')); + expect(result.current).toBe(false); + }); +}); + +describe('useIsMobile', () => { + it('is true when the viewport is below the lg breakpoint', () => { + installMatchMedia(true); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(true); + }); + + it('is false on wide viewports', () => { + installMatchMedia(false); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + }); +}); diff --git a/calm-hub-ui/src/hooks/useMediaQuery.ts b/calm-hub-ui/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..d8ac68025 --- /dev/null +++ b/calm-hub-ui/src/hooks/useMediaQuery.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +/** + * Subscribe to a CSS media query and return whether it currently matches. + * + * Safe to call in environments without `window.matchMedia` (e.g. older jsdom + * test setups): it returns `false` and never subscribes, so components fall + * back to their desktop layout. + */ +export function useMediaQuery(query: string): boolean { + const getMatches = (): boolean => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(query).matches; + }; + + const [matches, setMatches] = useState(getMatches); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + const mediaQueryList = window.matchMedia(query); + const handleChange = () => setMatches(mediaQueryList.matches); + + // Sync immediately in case the query changed between render and effect. + handleChange(); + mediaQueryList.addEventListener('change', handleChange); + return () => mediaQueryList.removeEventListener('change', handleChange); + }, [query]); + + return matches; +} + +/** + * Tailwind's `lg` breakpoint is 1024px. Anything narrower (phones and + * tablet-portrait) is treated as "mobile" and gets the off-canvas layout. + */ +export function useIsMobile(): boolean { + return useMediaQuery('(max-width: 1023px)'); +} diff --git a/calm-hub-ui/src/hub/Hub.test.tsx b/calm-hub-ui/src/hub/Hub.test.tsx index 11159813e..4862baa22 100644 --- a/calm-hub-ui/src/hub/Hub.test.tsx +++ b/calm-hub-ui/src/hub/Hub.test.tsx @@ -2,7 +2,28 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import Hub from './Hub.js'; -import { vi, describe, it, expect } from 'vitest'; +import { vi, describe, it, expect, afterEach } from 'vitest'; + +/** + * Force `useIsMobile()` (which reads window.matchMedia) to report a mobile + * viewport. Returns a restore function. + */ +function mockMobileViewport(isMobile: boolean) { + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: isMobile, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + return () => { + window.matchMedia = original; + }; +} vi.mock('./components/tree-navigation/TreeNavigation', () => ({ TreeNavigation: ({ @@ -268,4 +289,55 @@ describe('Hub', () => { expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); }); }); + + describe('mobile layout', () => { + afterEach(() => { + // Restore the default desktop matchMedia mock from vitest.setup.ts. + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + }); + + it('hides the tree navigation behind a menu button by default', () => { + const restore = mockMobileViewport(true); + renderWithRouter(); + + // Tree is not rendered until the drawer is opened. + expect(screen.queryByTestId('tree-navigation')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Open navigation')).toBeInTheDocument(); + + restore(); + }); + + it('opens the tree navigation drawer when the menu button is clicked', () => { + const restore = mockMobileViewport(true); + renderWithRouter(); + + fireEvent.click(screen.getByLabelText('Open navigation')); + expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); + + restore(); + }); + + it('closes the drawer after a resource is loaded', () => { + const restore = mockMobileViewport(true); + renderWithRouter(); + + fireEvent.click(screen.getByLabelText('Open navigation')); + expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Load Test Data')); + expect(screen.queryByTestId('tree-navigation')).not.toBeInTheDocument(); + expect(screen.getByTestId('diagram-section')).toBeInTheDocument(); + + restore(); + }); + }); }); diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index 9ffd0434e..6465d6c67 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; -import { IoChevronForwardOutline } from 'react-icons/io5'; +import { IoChevronForwardOutline, IoMenuOutline } from 'react-icons/io5'; import { TreeNavigation } from './components/tree-navigation/TreeNavigation.js'; +import { useIsMobile } from '../hooks/useMediaQuery.js'; import { Data, Adr } from '../model/calm.js'; import { ControlData } from '../model/control.js'; import { InterfaceData } from '../model/interface.js'; @@ -20,7 +21,9 @@ export default function Hub() { const [controlData, setControlData] = useState(); const [interfaceData, setInterfaceData] = useState(); const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isMobileNavOpen, setIsMobileNavOpen] = useState(false); const [selectedItem, setSelectedItem] = useState(null); + const isMobile = useIsMobile(); function handleDataLoad(data: Data) { setData(data); @@ -28,6 +31,7 @@ export default function Hub() { setControlData(undefined); setInterfaceData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } function handleAdrLoad(adr: Adr) { @@ -36,6 +40,7 @@ export default function Hub() { setControlData(undefined); setInterfaceData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } function handleControlLoad(control: ControlData) { @@ -44,6 +49,7 @@ export default function Hub() { setAdrData(undefined); setInterfaceData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } function handleInterfaceLoad(iface: InterfaceData) { @@ -52,6 +58,7 @@ export default function Hub() { setAdrData(undefined); setControlData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } const handleItemSelect = useCallback((item: SelectedItem) => { @@ -67,44 +74,100 @@ export default function Hub() { const memoizedDataLoad = useMemo(() => handleDataLoad, []); const memoizedAdrLoad = useMemo(() => handleAdrLoad, []); + const treeNavigation = ( + setIsMobileNavOpen(false) : () => setIsSidebarOpen(false)} + /> + ); + + const detailContent = interfaceData ? ( + + ) : controlData ? ( + + ) : adrData ? ( + + ) : isDiagramView ? ( + + ) : ( + + ); + return (
-
-
-
- {isSidebarOpen ? ( -
- setIsSidebarOpen(false)} /> -
- ) : ( -
- +
+ {/* Desktop: inline, collapsible tree-navigation column */} + {!isMobile && ( +
+
+ {isSidebarOpen ? ( +
{treeNavigation}
+ ) : ( +
+ +
+ )} +
+
+ )} + + {/* Mobile: off-canvas tree-navigation drawer */} + {isMobile && isMobileNavOpen && ( +
+
-
-
- {interfaceData ? ( - - ) : controlData ? ( - - ) : adrData ? ( - - ) : isDiagramView ? ( - - ) : ( - + )} + +
+ {isMobile && ( +
+ +
)} +
{detailContent}
+ {selectedItem && isDiagramView && ( - + isMobile ? ( +
+
+ ) : ( + + ) )}
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 30a1748e7..3805235dd 100644 --- a/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx +++ b/calm-hub-ui/src/hub/components/section-header/SectionHeader.tsx @@ -38,8 +38,8 @@ export function SectionHeader({ icon, namespace, id, version, rightContent, vers return (
-
-

+
+

{icon} {namespace} {typeLabel && ( @@ -77,7 +77,7 @@ export function SectionHeader({ icon, namespace, id, version, rightContent, vers {rightContent}

{showShareBar && ( -
+
+ )}
{isMobile && ( -
- -
+ )}
{detailContent}
diff --git a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx index 14fc9f277..8950cd27e 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { IoConstructOutline, IoGridOutline, IoEyeOutline, IoCodeOutline, IoRocketOutline } from 'react-icons/io5'; +import { IoConstructOutline, IoGridOutline, IoEyeOutline, IoCodeOutline, IoRocketOutline, IoTimeOutline, IoCloseOutline } from 'react-icons/io5'; +import { useIsMobile } from '../../../hooks/useMediaQuery.js'; import { Data } from '../../../model/calm.js'; import { sortVersionsDescending } from '../../../model/version.js'; import { JsonRenderer } from '../json-renderer/JsonRenderer.js'; @@ -41,6 +42,8 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramS const navigate = useNavigate(); const tabParam = searchParams.get('tab') as DiagramTabType | null; const activeTab: DiagramTabType = tabParam ?? 'diagram'; + const isMobile = useIsMobile(); + const [showTimeline, setShowTimeline] = useState(false); const calmService = useMemo(() => new CalmService(), []); const [decorators, setDecorators] = useState([]); const [compareFrom, setCompareFrom] = useState(null); @@ -278,28 +281,31 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramS
{isArchitecture && ( )}
@@ -319,7 +325,7 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramS rightContent={tabs} /> -
+
{comparing ? (
)} + + {/* Mobile: timeline is tucked behind a floating button so it + doesn't permanently occupy the short viewport. */} + {isMobile && !showTimeline && ( + + )}
- + {!isMobile && ( + + )}
+ + {isMobile && showTimeline && ( +
+ +
+ { handleVersionChange(v); setShowTimeline(false); }} + onCompare={startCompare} + initialExpanded + /> +
+

+ )}
); } diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.tsx index 452870243..59856155c 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/TimelineBar.tsx @@ -79,6 +79,12 @@ interface TimelineBarProps { * exists. Provided by DiagramSection so this component stays service-free. */ loadChangesForVersion?: (prevVersion: string, currVersion: string) => Promise; + /** + * Force the initial expand state and skip the persisted preference. Used by + * the mobile bottom-sheet, which always wants the bar opened to the cards + * view (and shouldn't leak that into the desktop inline bar's saved state). + */ + initialExpanded?: boolean; } /** @@ -103,18 +109,22 @@ export function TimelineBar({ onNavigate, onCompare, loadChangesForVersion, + initialExpanded, }: TimelineBarProps) { - const [expanded, setExpanded] = useState(readExpanded); + const [expanded, setExpanded] = useState(() => initialExpanded ?? readExpanded()); const [hasSeenTimeline, setHasSeenTimeline] = useState(readSeen); // Remember the expand/collapse choice so a refresh keeps the bar as it was. + // Skipped when initialExpanded is forced (mobile sheet) so it doesn't leak + // into the desktop inline bar's saved preference. useEffect(() => { + if (initialExpanded !== undefined) return; try { localStorage.setItem(EXPANDED_STORAGE_KEY, String(expanded)); } catch { /* ignore unavailable storage */ } - }, [expanded]); + }, [expanded, initialExpanded]); const markSeen = useCallback(() => { setHasSeenTimeline((prev) => { diff --git a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx index 8e34914ac..e31a24ecd 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx @@ -21,6 +21,7 @@ import { parseCALMData } from './utils/calmTransformer.js'; import { getMatchingNodeIds, isEdgeVisible, getUniqueNodeTypes } from './utils/searchUtils.js'; import { useGraphInteractions } from './hooks/useGraphInteractions.js'; import { applyStoredPositions } from '../../services/node-position-service.js'; +import { useIsMobile } from '../../../hooks/useMediaQuery.js'; import type { ArchitectureGraphProps } from '../../contracts/contracts.js'; const edgeTypes = { custom: FloatingEdge }; @@ -46,6 +47,7 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport const sourceNodesRef = useRef([]); const [availableNodeTypes, setAvailableNodeTypes] = useState([]); + const isMobile = useIsMobile(); const { onNodesChange, @@ -135,14 +137,16 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport borderRadius: '8px', }} /> - + {!isMobile && ( + + )} ([]); const [decisionPoints, setDecisionPoints] = useState>([]); + const isMobile = useIsMobile(); const { onNodesChange, @@ -199,14 +201,16 @@ export function PatternGraph({ patternData, onNodeClick, onEdgeClick, viewportKe borderRadius: '8px', }} /> - + {!isMobile && ( + + )} setExpanded(true)} + style={iconButtonStyle} + > + + + ); + } + return (
onSearchChange(e.target.value)} + autoFocus={isMobile && expanded} style={{ border: 'none', outline: 'none', background: 'transparent', color: THEME.colors.foreground, fontSize: '12px', - width: '140px', + width: isMobile ? '110px' : '140px', }} /> {searchTerm && ( @@ -84,6 +122,28 @@ export function SearchBar({ ))} )} + {isMobile && ( + + )}
); } From 8d1490f4d4b877169686055a617ab41024525ebc Mon Sep 17 00:00:00 2001 From: Matthew Bain Date: Sat, 20 Jun 2026 13:47:54 +0000 Subject: [PATCH 03/17] feat(calm-hub-ui): make mobile menu and detail panels full-screen On mobile the tree-navigation ("Explore") menu and the node/relationship detail panel were partial floating cards over a dimmed backdrop. Promote them to full-screen panels that slide in (the menu from the left, the detail from the right), each dismissed via its own header control rather than a backdrop tap. - Hub: tree-navigation panel is now fixed inset-0 full-screen (kept mounted and slid off-canvas so deep-link/search loading still runs); the details overlay is full-screen with a slide-in-right animation and no backdrop. - Sidebar: drop the card padding/rounding/shadow on mobile so it fills the screen edge-to-edge; keep the desktop card (lg:p-4 / lg:rounded-box). - Add a slide-in-right keyframe utility for the detail push animation. - Update the Hub mobile tests to assert open/closed via the dialog role. Signed-off-by: Matthew Bain --- calm-hub-ui/src/hub/Hub.test.tsx | 19 +++---- calm-hub-ui/src/hub/Hub.tsx | 49 +++++++------------ calm-hub-ui/src/index.css | 15 ++++++ .../visualizer/components/sidebar/Sidebar.tsx | 4 +- 4 files changed, 45 insertions(+), 42 deletions(-) diff --git a/calm-hub-ui/src/hub/Hub.test.tsx b/calm-hub-ui/src/hub/Hub.test.tsx index fa3267970..b7b8727cf 100644 --- a/calm-hub-ui/src/hub/Hub.test.tsx +++ b/calm-hub-ui/src/hub/Hub.test.tsx @@ -310,36 +310,37 @@ describe('Hub', () => { renderWithRouter(); // Tree stays mounted (so deep-link / search loading still runs) but the - // drawer is closed — no backdrop — until the menu button is pressed. + // full-screen panel is closed (aria-hidden, so excluded from the dialog + // role) until the menu button is pressed. expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); expect(screen.getByLabelText('Open navigation')).toBeInTheDocument(); - expect(screen.queryByLabelText('Close navigation')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); restore(); }); - it('opens the tree navigation drawer when the menu button is clicked', () => { + it('opens the full-screen tree navigation panel when the menu button is clicked', () => { const restore = mockMobileViewport(true); renderWithRouter(); fireEvent.click(screen.getByLabelText('Open navigation')); expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); - // Backdrop present means the drawer is open. - expect(screen.getByLabelText('Close navigation')).toBeInTheDocument(); + // The panel is now exposed (not aria-hidden), so the dialog is present. + expect(screen.getByRole('dialog')).toBeInTheDocument(); restore(); }); - it('closes the drawer after a resource is loaded', () => { + it('closes the panel after a resource is loaded', () => { const restore = mockMobileViewport(true); renderWithRouter(); fireEvent.click(screen.getByLabelText('Open navigation')); - expect(screen.getByLabelText('Close navigation')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); fireEvent.click(screen.getByText('Load Test Data')); - // Drawer closes (backdrop gone) but the tree remains mounted. - expect(screen.queryByLabelText('Close navigation')).not.toBeInTheDocument(); + // Panel closes (aria-hidden again) but the tree remains mounted. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); expect(screen.getByTestId('diagram-section')).toBeInTheDocument(); diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index ef92e0243..09114a4fd 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -121,29 +121,19 @@ export default function Hub() {
)} - {/* Mobile: off-canvas tree-navigation drawer. Kept mounted (slid off - screen) so deep-link / global-search loading — which lives inside - TreeNavigation — runs even while the drawer is closed. */} + {/* Mobile: full-screen tree-navigation panel that slides in from the + left. Kept mounted (slid off screen) so deep-link / global-search + loading — which lives inside TreeNavigation — runs even while the + panel is closed. Dismissed via the panel's own back button. */} {isMobile && ( - <> - {isMobileNavOpen && ( - + +
+ ), +})); + vi.mock('./components/json-renderer/JsonRenderer', () => ({ JsonRenderer: ({ json }: { json: unknown }) => (
{json ? 'JSON' : ''}
@@ -305,26 +334,28 @@ describe('Hub', () => { })) as unknown as typeof window.matchMedia; }); - it('keeps tree navigation mounted off-canvas with a menu button by default', () => { + it('keeps the drill-down menu mounted off-canvas with a menu button by default', () => { const restore = mockMobileViewport(true); renderWithRouter(); - // Tree stays mounted (so deep-link / search loading still runs) but the - // full-screen panel is closed (aria-hidden, so excluded from the dialog - // role) until the menu button is pressed. - expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); + // The drill-down menu stays mounted (so deep-link / search loading still + // runs) but the full-screen panel is closed (aria-hidden, so excluded from + // the dialog role) until the menu button is pressed. The desktop tree is + // not rendered on mobile. + expect(screen.getByTestId('mobile-nav-menu')).toBeInTheDocument(); + expect(screen.queryByTestId('tree-navigation')).not.toBeInTheDocument(); expect(screen.getByLabelText('Open navigation')).toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); restore(); }); - it('opens the full-screen tree navigation panel when the menu button is clicked', () => { + it('opens the full-screen drill-down panel when the menu button is clicked', () => { const restore = mockMobileViewport(true); renderWithRouter(); fireEvent.click(screen.getByLabelText('Open navigation')); - expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); + expect(screen.getByTestId('mobile-nav-menu')).toBeInTheDocument(); // The panel is now exposed (not aria-hidden), so the dialog is present. expect(screen.getByRole('dialog')).toBeInTheDocument(); @@ -338,10 +369,10 @@ describe('Hub', () => { fireEvent.click(screen.getByLabelText('Open navigation')); expect(screen.getByRole('dialog')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Load Test Data')); - // Panel closes (aria-hidden again) but the tree remains mounted. + fireEvent.click(screen.getByText('Mobile Load Test Data')); + // Panel closes (aria-hidden again) but the menu remains mounted. expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); + expect(screen.getByTestId('mobile-nav-menu')).toBeInTheDocument(); expect(screen.getByTestId('diagram-section')).toBeInTheDocument(); restore(); diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index 09114a4fd..d027a1916 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; import { IoChevronForwardOutline, IoMenuOutline } from 'react-icons/io5'; import { TreeNavigation } from './components/tree-navigation/TreeNavigation.js'; +import { MobileNavMenu } from './components/tree-navigation/MobileNavMenu.js'; import { useIsMobile } from '../hooks/useMediaQuery.js'; import { Data, Adr } from '../model/calm.js'; import { ControlData } from '../model/control.js'; @@ -80,7 +81,7 @@ export default function Hub() { onAdrLoad={memoizedAdrLoad} onControlLoad={handleControlLoad} onInterfaceLoad={handleInterfaceLoad} - onCollapse={isMobile ? () => setIsMobileNavOpen(false) : () => setIsSidebarOpen(false)} + onCollapse={() => setIsSidebarOpen(false)} /> ); @@ -121,10 +122,10 @@ export default function Hub() {
)} - {/* Mobile: full-screen tree-navigation panel that slides in from the - left. Kept mounted (slid off screen) so deep-link / global-search - loading — which lives inside TreeNavigation — runs even while the - panel is closed. Dismissed via the panel's own back button. */} + {/* Mobile: full-screen drill-down navigation panel that slides in from + the left. Kept mounted (slid off screen) so deep-link / global-search + loading — which lives inside MobileNavMenu — runs even while the panel + is closed. Dismissed via the panel's own close button. */} {isMobile && (
-
{treeNavigation}
+
+ setIsMobileNavOpen(false)} + /> +
)} 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 new file mode 100644 index 000000000..4a23dc9fe --- /dev/null +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useNavigate, useParams } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; +import { MobileNavMenu } from './MobileNavMenu.js'; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(), + }; +}); + +vi.mock('../../../service/calm-service.js', () => ({ + CalmService: vi.fn().mockImplementation(function () { return ({ + fetchNamespaces: vi.fn().mockResolvedValue(['finos', 'traderx']), + fetchArchitectureSummaries: vi.fn().mockResolvedValue([{ id: 1, name: 'Arch One' }]), + fetchPatternSummaries: vi.fn().mockResolvedValue([]), + fetchFlowSummaries: vi.fn().mockResolvedValue([]), + fetchStandardSummaries: vi.fn().mockResolvedValue([]), + fetchArchitectureVersions: vi.fn().mockResolvedValue(['1.0.0']), + fetchPatternVersions: vi.fn().mockResolvedValue([]), + fetchFlowVersions: vi.fn().mockResolvedValue([]), + fetchStandardVersions: vi.fn().mockResolvedValue([]), + fetchArchitecture: vi.fn().mockResolvedValue({}), + fetchVersionsByCustomId: vi.fn().mockResolvedValue([]), + fetchResourceByCustomId: vi.fn().mockResolvedValue({}), + }); }), +})); + +vi.mock('../../../service/control-service.js', () => ({ + ControlService: vi.fn().mockImplementation(function () { return ({ + fetchDomains: vi.fn().mockResolvedValue(['security']), + fetchControlsForDomain: vi.fn().mockResolvedValue([{ id: 5, name: 'Encryption', description: 'Encrypt data' }]), + }); }), +})); + +vi.mock('../../../service/interface-service.js', () => ({ + InterfaceService: vi.fn().mockImplementation(function () { return ({ + fetchInterfacesForNamespace: vi.fn().mockResolvedValue([]), + }); }), +})); + +vi.mock('../../../service/adr-service/adr-service.js', () => ({ + AdrService: vi.fn().mockImplementation(function () { return ({ + fetchAdrSummaries: vi.fn().mockResolvedValue([]), + fetchAdrRevisions: vi.fn().mockResolvedValue([]), + fetchAdr: vi.fn().mockResolvedValue({}), + }); }), +})); + +const props = { + onDataLoad: vi.fn(), + onAdrLoad: vi.fn(), + onControlLoad: vi.fn(), + onInterfaceLoad: vi.fn(), + onClose: vi.fn(), +}; + +const renderMenu = () => + render( + + + + ); + +beforeEach(() => { + vi.clearAllMocks(); + (useParams as Mock).mockReturnValue({}); + (useNavigate as Mock).mockReturnValue(vi.fn()); +}); + +describe('MobileNavMenu', () => { + it('renders the root level with Namespaces and Control Domains', () => { + renderMenu(); + expect(screen.getByRole('heading', { name: 'Explore' })).toBeInTheDocument(); + expect(screen.getByText('Namespaces')).toBeInTheDocument(); + expect(screen.getByText('Control Domains')).toBeInTheDocument(); + // No back button at the root. + expect(screen.queryByLabelText('Back')).not.toBeInTheDocument(); + }); + + it('drills into namespaces and shows resource types', async () => { + renderMenu(); + fireEvent.click(screen.getByText('Namespaces')); + expect(await screen.findByText('traderx')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('traderx')); + // Resource types level + expect(await screen.findByText('Architectures')).toBeInTheDocument(); + expect(screen.getByText('Interfaces')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'traderx' })).toBeInTheDocument(); + }); + + it('navigates to a resource and closes when a leaf is selected', async () => { + const navigate = vi.fn(); + (useNavigate as Mock).mockReturnValue(navigate); + renderMenu(); + + fireEvent.click(screen.getByText('Namespaces')); + fireEvent.click(await screen.findByText('traderx')); + fireEvent.click(await screen.findByText('Architectures')); + fireEvent.click(await screen.findByText('Arch One')); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith('/traderx/architectures/1/1.0.0'); + }); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('drills into control domains and lists controls', async () => { + renderMenu(); + fireEvent.click(screen.getByText('Control Domains')); + fireEvent.click(await screen.findByText('security')); + expect(await screen.findByText('Encryption')).toBeInTheDocument(); + }); + + it('goes back up a level with the back button', async () => { + renderMenu(); + fireEvent.click(screen.getByText('Namespaces')); + fireEvent.click(await screen.findByText('traderx')); + expect(await screen.findByText('Architectures')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Back')); + // Back to the namespaces list + expect(await screen.findByText('traderx')).toBeInTheDocument(); + expect(screen.queryByText('Architectures')).not.toBeInTheDocument(); + }); + + it('loads a resource from a deep-link via URL params', async () => { + (useParams as Mock).mockReturnValue({ + namespace: 'traderx', + type: 'architectures', + id: '1', + version: '1.0.0', + }); + renderMenu(); + await waitFor(() => { + expect(props.onDataLoad).toHaveBeenCalled(); + }); + }); +}); diff --git a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx new file mode 100644 index 000000000..e141e6a88 --- /dev/null +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx @@ -0,0 +1,349 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { IoChevronBackOutline, IoChevronForwardOutline, IoCompassOutline, IoCloseOutline } from 'react-icons/io5'; +import { useNavigate, useParams } from 'react-router-dom'; +import { CalmService } from '../../../service/calm-service.js'; +import { ControlService } from '../../../service/control-service.js'; +import { InterfaceService } from '../../../service/interface-service.js'; +import { AdrService } from '../../../service/adr-service/adr-service.js'; +import { Data, Adr, ResourceSummary, isSlug } from '../../../model/calm.js'; +import { pickLatestVersion } from '../../../model/version.js'; +import { ControlData, ControlDetail } from '../../../model/control.js'; +import { InterfaceData } from '../../../model/interface.js'; +import { + type TypeInUrl, + type TypeInUI, + mapTypeInUrlToTypeInUI, + mapTypeInUIToTypeInUrl, + loadResource, + loadResourceForId, + fetchVersionsForResource, +} from './navigation-loaders.js'; + +const RESOURCE_TYPES: TypeInUI[] = ['Architectures', 'Patterns', 'Flows', 'Standards', 'ADRs', 'Interfaces']; + +interface MobileNavMenuProps { + onDataLoad: (data: Data) => void; + onAdrLoad: (adr: Adr) => void; + onControlLoad: (control: ControlData) => void; + onInterfaceLoad: (iface: InterfaceData) => void; + /** Dismiss the menu (e.g. after a resource is chosen). */ + onClose: () => void; +} + +type HubParams = { + namespace: string; + type: TypeInUrl; + id: string; + version: string; +}; + +/** A single level in the drill-down stack. */ +type View = + | { level: 'root' } + | { level: 'namespaces' } + | { level: 'types'; namespace: string } + | { level: 'resources'; namespace: string; type: TypeInUI } + | { level: 'domains' } + | { level: 'controls'; domain: string }; + +interface LeafItem { + id: string; + name: string; +} + +/** + * 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; the deep-link effect (below) loads whatever the URL points at, + * mirroring the desktop {@link TreeNavigation} loading behaviour. + */ +export function MobileNavMenu({ onDataLoad, onAdrLoad, onControlLoad, onInterfaceLoad, onClose }: MobileNavMenuProps) { + const navigate = useNavigate(); + const params = useParams(); + + const calmService = useMemo(() => new CalmService(), []); + const controlService = useMemo(() => new ControlService(), []); + const interfaceService = useMemo(() => new InterfaceService(), []); + const adrService = useMemo(() => new AdrService(), []); + + const onControlLoadRef = useRef(onControlLoad); + onControlLoadRef.current = onControlLoad; + const onInterfaceLoadRef = useRef(onInterfaceLoad); + onInterfaceLoadRef.current = onInterfaceLoad; + + const [view, setView] = useState({ level: 'root' }); + const [namespaces, setNamespaces] = useState([]); + const [domains, setDomains] = useState([]); + const [leafItems, setLeafItems] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + calmService.fetchNamespaces().then(setNamespaces).catch(() => setNamespaces([])); + controlService.fetchDomains().then(setDomains).catch(() => setDomains([])); + }, [calmService, controlService]); + + // Deep-link / external navigation loading. Kept here (not just in the desktop + // tree) so direct URLs and the global search still load on mobile, where the + // tree is not rendered. + useEffect(() => { + if (!(params.namespace && params.type && params.id && params.version)) return; + const uiType = mapTypeInUrlToTypeInUI(params.type); + const namespace = params.namespace; + + if (uiType === 'Interfaces') { + interfaceService + .fetchInterfacesForNamespace(namespace) + .then((interfaces) => { + const match = interfaces.find((i) => i.id === Number(params.id)); + if (match) { + onInterfaceLoadRef.current({ + namespace, + interfaceId: match.id, + interfaceName: match.name, + interfaceDescription: match.description, + }); + } + }) + .catch(() => undefined); + return; + } + + if (uiType === 'Controls') { + controlService + .fetchControlsForDomain(namespace) + .then((controls) => { + const match = controls.find((c) => c.id === Number(params.id)); + if (match) { + onControlLoadRef.current({ + domain: namespace, + controlId: match.id, + controlName: match.name, + controlDescription: match.description, + }); + } + }) + .catch(() => undefined); + return; + } + + if (isSlug(params.id)) { + loadResourceForId(params.version, uiType, namespace, params.id, calmService, onDataLoad); + } else { + loadResource({ + version: params.version, + type: uiType, + namespace, + resourceID: params.id, + calmService, + onDataLoad, + onAdrLoad, + adrService, + }); + } + }, [params, calmService, adrService, interfaceService, controlService, onDataLoad, onAdrLoad]); + + const openType = useCallback( + (namespace: string, type: TypeInUI) => { + setView({ level: 'resources', namespace, type }); + setLeafItems([]); + setLoading(true); + const finish = (items: LeafItem[]) => { + setLeafItems(items); + setLoading(false); + }; + if (type === 'Interfaces') { + interfaceService + .fetchInterfacesForNamespace(namespace) + .then((ifaces) => finish(ifaces.map((i) => ({ id: i.id.toString(), name: i.name })))) + .catch(() => finish([])); + } else if (type === 'ADRs') { + adrService + .fetchAdrSummaries(namespace) + .then((adrs) => finish(adrs.map((a) => ({ id: a.id.toString(), name: `${a.title} (${a.status})` })))) + .catch(() => finish([])); + } else { + const fetcher = + type === 'Architectures' + ? calmService.fetchArchitectureSummaries.bind(calmService) + : type === 'Patterns' + ? calmService.fetchPatternSummaries.bind(calmService) + : type === 'Flows' + ? calmService.fetchFlowSummaries.bind(calmService) + : calmService.fetchStandardSummaries.bind(calmService); + fetcher(namespace) + .then((sums: ResourceSummary[]) => + finish(sums.map((s) => ({ id: s.customId ?? s.id.toString(), name: s.name }))) + ) + .catch(() => finish([])); + } + }, + [calmService, interfaceService, adrService] + ); + + const openDomain = useCallback( + (domain: string) => { + setView({ level: 'controls', domain }); + setLeafItems([]); + setLoading(true); + controlService + .fetchControlsForDomain(domain) + .then((controls: ControlDetail[]) => + setLeafItems(controls.map((c) => ({ id: c.id.toString(), name: c.name }))) + ) + .catch(() => setLeafItems([])) + .finally(() => setLoading(false)); + }, + [controlService] + ); + + const selectResource = useCallback( + async (id: string, type: TypeInUI, namespace: string) => { + if (type === 'Interfaces') { + navigate(`/${namespace}/interfaces/${id}/detail`); + onClose(); + return; + } + const versions = await fetchVersionsForResource(id, type, namespace, calmService, adrService); + const latest = pickLatestVersion(versions); + if (!latest) { + // arg1 is %s to prevent format string injection from `id`. + console.warn('No versions found for resource %s; nothing to load', id); + return; + } + navigate(`/${namespace}/${mapTypeInUIToTypeInUrl(type)}/${id}/${latest}`); + onClose(); + }, + [navigate, calmService, adrService, onClose] + ); + + const selectControl = useCallback( + (id: string, domain: string) => { + navigate(`/${domain}/controls/${id}/detail`); + onClose(); + }, + [navigate, onClose] + ); + + const goBack = useCallback(() => { + setView((current) => { + switch (current.level) { + case 'namespaces': + case 'domains': + return { level: 'root' }; + case 'types': + return { level: 'namespaces' }; + case 'resources': + return { level: 'types', namespace: current.namespace }; + case 'controls': + return { level: 'domains' }; + default: + return current; + } + }); + }, []); + + const title = + view.level === 'root' + ? 'Explore' + : view.level === 'namespaces' + ? 'Namespaces' + : view.level === 'types' + ? view.namespace + : view.level === 'resources' + ? view.type + : view.level === 'domains' + ? 'Control Domains' + : view.domain; + + const rows: { key: string; label: string; isLeaf: boolean; onClick: () => void }[] = (() => { + switch (view.level) { + case 'root': + return [ + { key: 'namespaces', label: 'Namespaces', isLeaf: false, onClick: () => setView({ level: 'namespaces' }) }, + { key: 'domains', label: 'Control Domains', isLeaf: false, onClick: () => setView({ level: 'domains' }) }, + ]; + case 'namespaces': + return namespaces.map((ns) => ({ + key: ns, + label: ns, + isLeaf: false, + onClick: () => setView({ level: 'types', namespace: ns }), + })); + case 'types': + return RESOURCE_TYPES.map((t) => ({ + key: t, + label: t, + isLeaf: false, + onClick: () => openType(view.namespace, t), + })); + case 'resources': + return leafItems.map((item) => ({ + key: item.id, + label: item.name, + isLeaf: true, + onClick: () => selectResource(item.id, view.type, view.namespace), + })); + case 'domains': + return domains.map((d) => ({ + key: d, + label: d, + isLeaf: false, + onClick: () => openDomain(d), + })); + case 'controls': + return leafItems.map((item) => ({ + key: item.id, + label: item.name, + isLeaf: true, + onClick: () => selectControl(item.id, view.domain), + })); + default: + return []; + } + })(); + + const isEmpty = !loading && rows.length === 0; + + return ( +
+
+ {view.level !== 'root' ? ( + + ) : ( + + )} +

{title}

+ +
+ +
    + {loading && ( +
  • + +
  • + )} + {isEmpty && ( +
  • Nothing here
  • + )} + {!loading && + rows.map((row) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/calm-hub-ui/src/hub/components/tree-navigation/TreeNavigation.tsx b/calm-hub-ui/src/hub/components/tree-navigation/TreeNavigation.tsx index c9f832402..abcb91684 100644 --- a/calm-hub-ui/src/hub/components/tree-navigation/TreeNavigation.tsx +++ b/calm-hub-ui/src/hub/components/tree-navigation/TreeNavigation.tsx @@ -6,6 +6,15 @@ import { InterfaceService } from '../../../service/interface-service.js'; import { AdrService } from '../../../service/adr-service/adr-service.js'; import { Data, Adr, ResourceSummary, AdrSummary, ResourceMapping, isSlug } from '../../../model/calm.js'; import { pickLatestVersion } from '../../../model/version.js'; +import { + type TypeInUrl, + type TypeInUI, + mapTypeInUrlToTypeInUI, + mapTypeInUIToTypeInUrl, + loadResource, + loadResourceForId, + fetchVersionsForResource, +} from './navigation-loaders.js'; function mapCalmTypeToResourceType(type: string): string { switch (type) { @@ -22,8 +31,6 @@ import { useNavigate, useParams } from 'react-router-dom'; import { DomainItem } from './DomainItem.js'; import { InterfaceItem } from './InterfaceItem.js'; -type TypeInUrl = 'architectures' | 'patterns' | 'flows' | 'adrs' | 'standards' | 'interfaces' | 'controls'; -type TypeInUI = 'Architectures' | 'Patterns' | 'Flows' | 'ADRs' | 'Standards' | 'Interfaces' | 'Controls'; type HubParams = { namespace: string; type: TypeInUrl; @@ -43,17 +50,6 @@ interface LoadResourceIdsOptions { setAdrSummaries: (summaries: AdrSummary[]) => void; } -interface LoadResourceOptions { - version: string; - type: string; - namespace: string; - resourceID: string; - calmService: CalmService; - onDataLoad: (data: Data) => void; - onAdrLoad: (adr: Adr) => void; - adrService: AdrService; -} - const basePath = ''; const EMPTY_STR_VALUE = ''; @@ -148,48 +144,6 @@ export function buildNamespaceTree(namespaces: string[]): NamespaceNode[] { return collapseToTree(root, ''); } -function mapTypeInUrlToTypeInUI(urlType: TypeInUrl): TypeInUI { - switch (urlType) { - case 'architectures': - return 'Architectures'; - case 'patterns': - return 'Patterns'; - case 'flows': - return 'Flows'; - case 'adrs': - return 'ADRs'; - case 'standards': - return 'Standards'; - case 'interfaces': - return 'Interfaces'; - case 'controls': - return 'Controls'; - default: - throw new Error(`Unhandled type: ${urlType}`); - } -} - -function mapTypeInUIToTypeInUrl(uiType: TypeInUI): TypeInUrl { - switch (uiType) { - case 'Architectures': - return 'architectures'; - case 'Patterns': - return 'patterns'; - case 'Flows': - return 'flows'; - case 'ADRs': - return 'adrs'; - case 'Standards': - return 'standards'; - case 'Interfaces': - return 'interfaces'; - case 'Controls': - return 'controls'; - default: - throw new Error(`Unhandled type: ${uiType}`); - } -} - function ResourceItem({ resourceID, displayName, @@ -385,67 +339,6 @@ function loadResourceIds({ } } -function loadResourceForId( - version: string, - type: string, - namespace: string, - resourceID: string, - calmService: CalmService, - onDataLoad: (data: Data) => void, -) { - if (isSlug(resourceID)) { - calmService.fetchResourceByCustomId(namespace, resourceID, version, type).then(onDataLoad); - } -} - -async function fetchVersionsForResource( - resourceID: string, - type: string, - namespace: string, - calmService: CalmService, - adrService: AdrService, -): Promise { - if (isSlug(resourceID) && type !== 'ADRs') { - return calmService.fetchVersionsByCustomId(namespace, resourceID, type); - } - switch (type) { - case 'Architectures': - return calmService.fetchArchitectureVersions(namespace, resourceID); - case 'Patterns': - return calmService.fetchPatternVersions(namespace, resourceID); - case 'Flows': - return calmService.fetchFlowVersions(namespace, resourceID); - case 'Standards': - return calmService.fetchStandardVersions(namespace, resourceID); - case 'ADRs': - return (await adrService.fetchAdrRevisions(namespace, resourceID)).map((rev) => rev.toString()); - default: - return []; - } -} - -function loadResource({ - version, - type, - namespace, - resourceID, - calmService, - onDataLoad, - onAdrLoad, - adrService -}: LoadResourceOptions) { - if (type === 'Architectures') { - calmService.fetchArchitecture(namespace, resourceID, version).then(onDataLoad); - } else if (type === 'Patterns') { - calmService.fetchPattern(namespace, resourceID, version).then(onDataLoad); - } else if (type === 'Flows') { - calmService.fetchFlow(namespace, resourceID, version).then(onDataLoad); - } else if (type === 'Standards') { - calmService.fetchStandard(namespace, resourceID, version).then(onDataLoad); - } else if (type === 'ADRs') { - adrService.fetchAdr(namespace, resourceID, version).then(onAdrLoad); - } -} export function TreeNavigation({ onDataLoad, onAdrLoad, onControlLoad, onInterfaceLoad, onCollapse }: TreeNavigationProps) { const navigate = useNavigate(); diff --git a/calm-hub-ui/src/hub/components/tree-navigation/navigation-loaders.ts b/calm-hub-ui/src/hub/components/tree-navigation/navigation-loaders.ts new file mode 100644 index 000000000..cab28dc2a --- /dev/null +++ b/calm-hub-ui/src/hub/components/tree-navigation/navigation-loaders.ts @@ -0,0 +1,121 @@ +import { CalmService } from '../../../service/calm-service.js'; +import { AdrService } from '../../../service/adr-service/adr-service.js'; +import { Data, Adr, isSlug } from '../../../model/calm.js'; + +export type TypeInUrl = 'architectures' | 'patterns' | 'flows' | 'adrs' | 'standards' | 'interfaces' | 'controls'; +export type TypeInUI = 'Architectures' | 'Patterns' | 'Flows' | 'ADRs' | 'Standards' | 'Interfaces' | 'Controls'; + +export interface LoadResourceOptions { + version: string; + type: string; + namespace: string; + resourceID: string; + calmService: CalmService; + onDataLoad: (data: Data) => void; + onAdrLoad: (adr: Adr) => void; + adrService: AdrService; +} + +export function mapTypeInUrlToTypeInUI(urlType: TypeInUrl): TypeInUI { + switch (urlType) { + case 'architectures': + return 'Architectures'; + case 'patterns': + return 'Patterns'; + case 'flows': + return 'Flows'; + case 'adrs': + return 'ADRs'; + case 'standards': + return 'Standards'; + case 'interfaces': + return 'Interfaces'; + case 'controls': + return 'Controls'; + default: + throw new Error(`Unhandled type: ${urlType}`); + } +} + +export function mapTypeInUIToTypeInUrl(uiType: TypeInUI): TypeInUrl { + switch (uiType) { + case 'Architectures': + return 'architectures'; + case 'Patterns': + return 'patterns'; + case 'Flows': + return 'flows'; + case 'ADRs': + return 'adrs'; + case 'Standards': + return 'standards'; + case 'Interfaces': + return 'interfaces'; + case 'Controls': + return 'controls'; + default: + throw new Error(`Unhandled type: ${uiType}`); + } +} + +export function loadResourceForId( + version: string, + type: string, + namespace: string, + resourceID: string, + calmService: CalmService, + onDataLoad: (data: Data) => void, +) { + if (isSlug(resourceID)) { + calmService.fetchResourceByCustomId(namespace, resourceID, version, type).then(onDataLoad); + } +} + +export async function fetchVersionsForResource( + resourceID: string, + type: string, + namespace: string, + calmService: CalmService, + adrService: AdrService, +): Promise { + if (isSlug(resourceID) && type !== 'ADRs') { + return calmService.fetchVersionsByCustomId(namespace, resourceID, type); + } + switch (type) { + case 'Architectures': + return calmService.fetchArchitectureVersions(namespace, resourceID); + case 'Patterns': + return calmService.fetchPatternVersions(namespace, resourceID); + case 'Flows': + return calmService.fetchFlowVersions(namespace, resourceID); + case 'Standards': + return calmService.fetchStandardVersions(namespace, resourceID); + case 'ADRs': + return (await adrService.fetchAdrRevisions(namespace, resourceID)).map((rev) => rev.toString()); + default: + return []; + } +} + +export function loadResource({ + version, + type, + namespace, + resourceID, + calmService, + onDataLoad, + onAdrLoad, + adrService, +}: LoadResourceOptions) { + if (type === 'Architectures') { + calmService.fetchArchitecture(namespace, resourceID, version).then(onDataLoad); + } else if (type === 'Patterns') { + calmService.fetchPattern(namespace, resourceID, version).then(onDataLoad); + } else if (type === 'Flows') { + calmService.fetchFlow(namespace, resourceID, version).then(onDataLoad); + } else if (type === 'Standards') { + calmService.fetchStandard(namespace, resourceID, version).then(onDataLoad); + } else if (type === 'ADRs') { + adrService.fetchAdr(namespace, resourceID, version).then(onAdrLoad); + } +} From fa52e9c45ecc606239a7a0a4a5bc58232d5a717f Mon Sep 17 00:00:00 2001 From: Matthew Bain Date: Sat, 20 Jun 2026 14:11:39 +0000 Subject: [PATCH 05/17] feat(calm-hub-ui): move explorer into the navbar; drop nav links and mobile zoom controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Navbar: remove the Hub and Visualizer links. Add an "Explore" button (shown when the page provides a handler) that toggles the explorer. - Hub: the navbar Explore button opens the mobile drill-down panel on mobile and toggles the desktop sidebar on desktop; remove the in-content Explore button. The Visualizer keeps no nav links (reachable via /visualizer). - Diagram: hide the ReactFlow zoom controls on mobile (single, pattern, and compare views) — pinch-to-zoom is the native touch interface; controls remain on desktop. - Tests: Navbar mock exposes the Explore toggle; add a desktop navbar-toggle test; update mobile tests accordingly. Signed-off-by: Matthew Bain --- calm-hub-ui/src/components/navbar/Navbar.tsx | 66 ++++++------------- calm-hub-ui/src/diff/components/DiffGraph.tsx | 16 +++-- calm-hub-ui/src/hub/Hub.test.tsx | 28 ++++++-- calm-hub-ui/src/hub/Hub.tsx | 14 +--- .../reactflow/ArchitectureGraph.tsx | 16 +++-- .../components/reactflow/PatternGraph.tsx | 16 +++-- 6 files changed, 74 insertions(+), 82 deletions(-) diff --git a/calm-hub-ui/src/components/navbar/Navbar.tsx b/calm-hub-ui/src/components/navbar/Navbar.tsx index 0980ae232..3d62bc42a 100644 --- a/calm-hub-ui/src/components/navbar/Navbar.tsx +++ b/calm-hub-ui/src/components/navbar/Navbar.tsx @@ -1,40 +1,30 @@ import './Navbar.css'; -import { NavLink } from 'react-router-dom'; +import { IoMenuOutline } from 'react-icons/io5'; import { GlobalSearchBar } from './GlobalSearchBar.js'; -export function Navbar() { +interface NavbarProps { + /** + * When provided, renders an "Explore" button in the navbar that toggles the + * navigation/explorer (the desktop sidebar, or the mobile drill-down panel). + * Pages without an explorer (e.g. the Visualizer) omit this. + */ + onExploreClick?: () => void; +} + +export function Navbar({ onExploreClick }: NavbarProps) { return (
-
-
-
- - - -
-
    + {onExploreClick && ( +
-
+ + Explore + + )} -
-
    -
  • - - Hub - -
  • -
  • - - Visualizer - -
  • -
-
diff --git a/calm-hub-ui/src/diff/components/DiffGraph.tsx b/calm-hub-ui/src/diff/components/DiffGraph.tsx index 8f97d156b..e9b0e0028 100644 --- a/calm-hub-ui/src/diff/components/DiffGraph.tsx +++ b/calm-hub-ui/src/diff/components/DiffGraph.tsx @@ -88,13 +88,15 @@ function DiffGraphInner({ source, sourceType, diffResult, isFirst }: DiffGraphPr style={{ background: THEME.colors.background }} > - + {!isMobile && ( + + )} {!isMobile && ( ({ })); vi.mock('../components/navbar/Navbar', () => ({ - Navbar: () => , + Navbar: ({ onExploreClick }: { onExploreClick?: () => void }) => ( + + ), })); vi.mock('./components/diagram-section/DiagramSection', () => ({ @@ -317,6 +326,17 @@ describe('Hub', () => { fireEvent.click(screen.getByLabelText('Expand sidebar')); expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); }); + + it('toggles the desktop sidebar from the navbar Explore button', () => { + renderWithRouter(); + expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Toggle explorer')); + expect(screen.queryByTestId('tree-navigation')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Toggle explorer')); + expect(screen.getByTestId('tree-navigation')).toBeInTheDocument(); + }); }); describe('mobile layout', () => { @@ -344,7 +364,7 @@ describe('Hub', () => { // not rendered on mobile. expect(screen.getByTestId('mobile-nav-menu')).toBeInTheDocument(); expect(screen.queryByTestId('tree-navigation')).not.toBeInTheDocument(); - expect(screen.getByLabelText('Open navigation')).toBeInTheDocument(); + expect(screen.getByLabelText('Toggle explorer')).toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); restore(); @@ -354,7 +374,7 @@ describe('Hub', () => { const restore = mockMobileViewport(true); renderWithRouter(); - fireEvent.click(screen.getByLabelText('Open navigation')); + fireEvent.click(screen.getByLabelText('Toggle explorer')); expect(screen.getByTestId('mobile-nav-menu')).toBeInTheDocument(); // The panel is now exposed (not aria-hidden), so the dialog is present. expect(screen.getByRole('dialog')).toBeInTheDocument(); @@ -366,7 +386,7 @@ describe('Hub', () => { const restore = mockMobileViewport(true); renderWithRouter(); - fireEvent.click(screen.getByLabelText('Open navigation')); + fireEvent.click(screen.getByLabelText('Toggle explorer')); expect(screen.getByRole('dialog')).toBeInTheDocument(); fireEvent.click(screen.getByText('Mobile Load Test Data')); diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index d027a1916..d78249cd4 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react'; -import { IoChevronForwardOutline, IoMenuOutline } from 'react-icons/io5'; +import { IoChevronForwardOutline } from 'react-icons/io5'; import { TreeNavigation } from './components/tree-navigation/TreeNavigation.js'; import { MobileNavMenu } from './components/tree-navigation/MobileNavMenu.js'; import { useIsMobile } from '../hooks/useMediaQuery.js'; @@ -99,7 +99,7 @@ export default function Hub() { return (
- + (isMobile ? setIsMobileNavOpen(true) : setIsSidebarOpen((v) => !v))} />
{/* Desktop: inline, collapsible tree-navigation column */} {!isMobile && ( @@ -146,16 +146,6 @@ export default function Hub() { )}
- {isMobile && ( - - )}
{detailContent}
diff --git a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx index e31a24ecd..3edd20e5a 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx @@ -130,13 +130,15 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport style={{ background: THEME.colors.background }} > - + {!isMobile && ( + + )} {!isMobile && ( - + {!isMobile && ( + + )} {!isMobile && ( Date: Sat, 20 Jun 2026 14:34:18 +0000 Subject: [PATCH 06/17] feat(calm-hub-ui): full-bleed render pane, view-modes menu, mobile explorer search - Diagram: make the render pane full-bleed (drop the section-header bar and card chrome) and move the Diagram/JSON/Deployments switch plus the breadcrumb into a hamburger menu pinned to the top-right of the canvas. Offset the in-canvas node search so it clears the menu. - Mobile search: add an always-visible search bar to the mobile explorer (MobileNavMenu); while a query is present the results take over the explorer body. Search stays in the navbar on desktop (hidden below lg), unchanged. - Add ExplorerSearch (inline, takeover results) alongside the existing navbar GlobalSearchBar. - Tests for ExplorerSearch; stub it in the MobileNavMenu test; adjust the DiagramSection view-mode assertions for the new menu. Signed-off-by: Matthew Bain --- .../components/navbar/ExplorerSearch.test.tsx | 213 ++++++++++++ .../src/components/navbar/ExplorerSearch.tsx | 318 ++++++++++++++++++ calm-hub-ui/src/components/navbar/Navbar.tsx | 4 +- calm-hub-ui/src/hub/Hub.tsx | 2 +- .../diagram-section/DiagramSection.tsx | 91 ++--- .../tree-navigation/MobileNavMenu.test.tsx | 6 + .../tree-navigation/MobileNavMenu.tsx | 52 +-- .../reactflow/ArchitectureGraph.tsx | 3 +- .../components/reactflow/PatternGraph.tsx | 3 +- 9 files changed, 621 insertions(+), 71 deletions(-) create mode 100644 calm-hub-ui/src/components/navbar/ExplorerSearch.test.tsx create mode 100644 calm-hub-ui/src/components/navbar/ExplorerSearch.tsx diff --git a/calm-hub-ui/src/components/navbar/ExplorerSearch.test.tsx b/calm-hub-ui/src/components/navbar/ExplorerSearch.test.tsx new file mode 100644 index 000000000..980e50622 --- /dev/null +++ b/calm-hub-ui/src/components/navbar/ExplorerSearch.test.tsx @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ExplorerSearch } from './ExplorerSearch.js'; +import { SearchService } from '../../service/search-service.js'; +import { GroupedSearchResults } from '../../model/search.js'; + +const emptyResults: GroupedSearchResults = { + architectures: [], + patterns: [], + flows: [], + standards: [], + interfaces: [], + controls: [], + adrs: [], +}; + +const mockResults: GroupedSearchResults = { + architectures: [{ namespace: 'finos', id: 1, name: 'Test Architecture', description: 'A test architecture' }], + patterns: [{ namespace: 'finos', id: 2, name: 'Test Pattern', description: 'A test pattern' }], + flows: [], + standards: [], + interfaces: [], + controls: [], + adrs: [], +}; + +function createMockSearchService(searchFn: (q: string) => Promise) { + return { search: searchFn } as unknown as SearchService; +} + +function renderSearch(searchService?: SearchService, onSearchingChange?: (a: boolean) => void) { + return render( + + + + ); +} + +describe('ExplorerSearch', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders search input', () => { + renderSearch(); + expect(screen.getByPlaceholderText('Search CALM Hub...')).toBeInTheDocument(); + }); + + it('debounces API calls', async () => { + const searchFn = vi.fn().mockResolvedValue(emptyResults); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 't' } }); + fireEvent.change(input, { target: { value: 'te' } }); + fireEvent.change(input, { target: { value: 'tes' } }); + fireEvent.change(input, { target: { value: 'test' } }); + }); + + expect(searchFn).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(searchFn).toHaveBeenCalledTimes(1); + expect(searchFn).toHaveBeenCalledWith('test'); + }); + + it('displays grouped results inline', async () => { + const searchFn = vi.fn().mockResolvedValue(mockResults); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(screen.getByText('Test Architecture')).toBeInTheDocument(); + expect(screen.getByText('Test Pattern')).toBeInTheDocument(); + expect(screen.getByText('Architectures')).toBeInTheDocument(); + expect(screen.getByText('Patterns')).toBeInTheDocument(); + }); + + it('notifies the parent when a search becomes active and inactive', async () => { + const onSearchingChange = vi.fn(); + const searchFn = vi.fn().mockResolvedValue(mockResults); + renderSearch(createMockSearchService(searchFn), onSearchingChange); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test' } }); + }); + expect(onSearchingChange).toHaveBeenLastCalledWith(true); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Clear search')); + }); + expect(onSearchingChange).toHaveBeenLastCalledWith(false); + }); + + it('shows no results message when search returns empty', async () => { + const searchFn = vi.fn().mockResolvedValue(emptyResults); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(screen.getByText('No results found')).toBeInTheDocument(); + }); + + it('navigates with keyboard ArrowDown and Enter', async () => { + const searchFn = vi.fn().mockResolvedValue(mockResults); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + await act(async () => { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + }); + + const firstOption = screen.getAllByRole('option')[0]; + expect(firstOption).toHaveAttribute('aria-selected', 'true'); + }); + + it('clears results on Escape', async () => { + const searchFn = vi.fn().mockResolvedValue(mockResults); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + await act(async () => { + fireEvent.keyDown(input, { key: 'Escape' }); + }); + + expect(screen.queryByText('Test Architecture')).not.toBeInTheDocument(); + }); + + it('clears search on clear button click', async () => { + const searchFn = vi.fn().mockResolvedValue(mockResults); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test' } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Clear search')); + }); + + expect(input).toHaveValue(''); + expect(screen.queryByText('Test Architecture')).not.toBeInTheDocument(); + }); + + it('handles API errors gracefully', async () => { + const searchFn = vi.fn().mockRejectedValue(new Error('Network error')); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'test' } }); + }); + await act(async () => { + vi.advanceTimersByTime(300); + await vi.runAllTimersAsync(); + }); + + expect(screen.getByText('Search failed, please try again')).toBeInTheDocument(); + }); + + it('does not search when input is empty', async () => { + const searchFn = vi.fn().mockResolvedValue(emptyResults); + renderSearch(createMockSearchService(searchFn)); + const input = screen.getByPlaceholderText('Search CALM Hub...'); + + await act(async () => { + fireEvent.change(input, { target: { value: ' ' } }); + }); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(searchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/calm-hub-ui/src/components/navbar/ExplorerSearch.tsx b/calm-hub-ui/src/components/navbar/ExplorerSearch.tsx new file mode 100644 index 000000000..cf61f8bc1 --- /dev/null +++ b/calm-hub-ui/src/components/navbar/ExplorerSearch.tsx @@ -0,0 +1,318 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { IoSearchOutline, IoCloseOutline } from 'react-icons/io5'; +import { SearchService } from '../../service/search-service.js'; +import { CalmService } from '../../service/calm-service.js'; +import { AdrService } from '../../service/adr-service/adr-service.js'; +import { GroupedSearchResults, SearchResult } from '../../model/search.js'; + +interface FlatResult { + type: string; + result: SearchResult; +} + +const TYPE_LABELS: Record = { + architectures: 'Architectures', + patterns: 'Patterns', + flows: 'Flows', + standards: 'Standards', + interfaces: 'Interfaces', + controls: 'Controls', + adrs: 'ADRs', +}; + +const TYPE_ROUTES: Record = { + architectures: 'architectures', + patterns: 'patterns', + flows: 'flows', + standards: 'standards', + interfaces: 'interfaces', + controls: 'controls', + adrs: 'adrs', +}; + +function flattenResults(grouped: GroupedSearchResults): FlatResult[] { + const flat: FlatResult[] = []; + for (const [type, results] of Object.entries(grouped)) { + for (const result of results as SearchResult[]) { + flat.push({ type, result }); + } + } + return flat; +} + +interface ExplorerSearchProps { + searchService?: SearchService; + calmService?: CalmService; + adrService?: AdrService; + /** Notifies the parent explorer when a search is active so it can hide its nav. */ + onSearchingChange?: (active: boolean) => void; +} + +/** + * Always-visible search bar for the explorer. While a query is present the + * results render inline and take over the explorer body (the parent hides its + * tree / drill-down). Selecting a result navigates to the resource. + */ +export function ExplorerSearch({ + searchService, + calmService: calmServiceProp, + adrService: adrServiceProp, + onSearchingChange, +}: ExplorerSearchProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + const abortControllerRef = useRef(null); + const service = useMemo(() => searchService ?? new SearchService(), [searchService]); + const calmService = useMemo(() => calmServiceProp ?? new CalmService(), [calmServiceProp]); + const adrService = useMemo(() => adrServiceProp ?? new AdrService(), [adrServiceProp]); + + const navigate = useNavigate(); + + const active = query.trim().length > 0; + const flatResults = results ? flattenResults(results) : []; + + useEffect(() => { + onSearchingChange?.(active); + }, [active, onSearchingChange]); + + const performSearch = useCallback( + async (searchQuery: string) => { + if (!searchQuery.trim()) { + setResults(null); + setError(false); + return; + } + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(false); + try { + const data = await service.search(searchQuery); + if (controller.signal.aborted) return; + setResults(data); + setSelectedIndex(-1); + } catch { + if (controller.signal.aborted) return; + setResults(null); + setError(true); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, + [service] + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + performSearch(value); + }, 300); + }, + [performSearch] + ); + + const resolveLatestVersion = useCallback( + async (type: string, namespace: string, id: string): Promise => { + let versions: (string | number)[]; + switch (type) { + case 'architectures': + versions = await calmService.fetchArchitectureVersions(namespace, id); + break; + case 'patterns': + versions = await calmService.fetchPatternVersions(namespace, id); + break; + case 'flows': + versions = await calmService.fetchFlowVersions(namespace, id); + break; + case 'standards': + versions = await calmService.fetchStandardVersions(namespace, id); + break; + case 'adrs': + versions = await adrService.fetchAdrRevisions(namespace, id); + break; + default: + throw new Error(`Unknown type: ${type}`); + } + if (!versions || versions.length === 0) throw new Error('No versions found'); + return String(versions[versions.length - 1]); + }, + [calmService, adrService] + ); + + const navigateToResult = useCallback( + (flatResult: FlatResult) => { + const { type, result } = flatResult; + setQuery(''); + setResults(null); + + if (type === 'controls') { + navigate(`/${result.namespace}/controls/${result.id}/detail`); + return; + } + + if (type === 'interfaces') { + navigate(`/${result.namespace}/interfaces/${result.id}/detail`); + return; + } + + const route = TYPE_ROUTES[type]; + const id = String(result.id); + resolveLatestVersion(type, result.namespace, id) + .then((version) => { + navigate(`/${result.namespace}/${route}/${id}/${version}`); + }) + .catch(() => { + navigate(`/${result.namespace}/${route}`); + }); + }, + [navigate, resolveLatestVersion] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!active || flatResults.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev < flatResults.length - 1 ? prev + 1 : 0)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : flatResults.length - 1)); + } else if (e.key === 'Enter' && selectedIndex >= 0) { + e.preventDefault(); + navigateToResult(flatResults[selectedIndex]); + } else if (e.key === 'Escape') { + handleClear(); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [active, flatResults, selectedIndex, navigateToResult] + ); + + const handleClear = useCallback(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + setQuery(''); + setResults(null); + setSelectedIndex(-1); + setError(false); + inputRef.current?.focus(); + }, []); + + useEffect(() => { + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + const renderGroupedResults = () => { + if (error) { + return
Search failed, please try again
; + } + + if (!results) { + return loading ? null : null; + } + + const groups = Object.entries(results).filter(([, items]) => (items as SearchResult[]).length > 0); + + if (groups.length === 0) { + return
No results found
; + } + + let globalIndex = 0; + + return groups.map(([type, items]) => ( +
+
+ {TYPE_LABELS[type] ?? type} +
+ {(items as SearchResult[]).map((item) => { + const currentIndex = globalIndex++; + return ( + + ); + })} +
+ )); + }; + + return ( + <> +
+ + + {loading && } + {query && ( + + )} +
+ {active && ( +
+ {renderGroupedResults()} +
+ )} + + ); +} diff --git a/calm-hub-ui/src/components/navbar/Navbar.tsx b/calm-hub-ui/src/components/navbar/Navbar.tsx index 3d62bc42a..452751acf 100644 --- a/calm-hub-ui/src/components/navbar/Navbar.tsx +++ b/calm-hub-ui/src/components/navbar/Navbar.tsx @@ -33,7 +33,9 @@ export function Navbar({ onExploreClick }: NavbarProps) { />
-
+ {/* Desktop keeps search in the navbar; on mobile it lives inside the + explorer panel instead, so it is hidden here below the lg breakpoint. */} +
diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index d78249cd4..a26eb8aef 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -92,7 +92,7 @@ export default function Hub() { ) : adrData ? ( ) : isDiagramView ? ( - + ) : ( ); diff --git a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx index 8950cd27e..fea520e25 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -1,12 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { IoConstructOutline, IoGridOutline, IoEyeOutline, IoCodeOutline, IoRocketOutline, IoTimeOutline, IoCloseOutline } from 'react-icons/io5'; +import { IoConstructOutline, IoGridOutline, IoEyeOutline, IoCodeOutline, IoRocketOutline, IoTimeOutline, IoCloseOutline, IoMenuOutline } from 'react-icons/io5'; import { useIsMobile } from '../../../hooks/useMediaQuery.js'; import { Data } from '../../../model/calm.js'; import { sortVersionsDescending } from '../../../model/version.js'; import { JsonRenderer } from '../json-renderer/JsonRenderer.js'; import { Drawer } from '../../../visualizer/components/drawer/Drawer.js'; -import { SectionHeader } from '../section-header/SectionHeader.js'; import { DeploymentPanel } from '../../../visualizer/components/reactflow/DeploymentPanel.js'; import { CompareView } from './compare/CompareView.js'; import { diffArchitectures, diffPatterns, type NodesAndRelationshipsDiffResult } from '@finos/calm-models/diff'; @@ -27,7 +26,6 @@ import type { DeploymentDecorator, SelectedItem } from '../../../visualizer/cont interface DiagramSectionProps { data: Data & { calmType: 'Architectures' | 'Patterns' }; onItemSelect?: (item: SelectedItem) => void; - hasDetailsPanel?: boolean; } const iconMap = { @@ -37,7 +35,7 @@ const iconMap = { type DiagramTabType = 'diagram' | 'json' | 'deployments'; -export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramSectionProps) { +export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const tabParam = searchParams.get('tab') as DiagramTabType | null; @@ -277,55 +275,60 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramS const Icon = iconMap[data.calmType]; const comparing = compareFrom !== null && compareTo !== null; - const tabs = ( -
+ const tabButtonClass = (tab: DiagramTabType) => + `btn btn-sm justify-start gap-2 ${!comparing && activeTab === tab ? 'tab-active !bg-accent !text-white' : 'btn-ghost'}`; + + // The render pane is full-bleed; the breadcrumb and view-mode switch live in + // a hamburger menu pinned to the top-right of the canvas. + const viewMenu = ( +
- - {isArchitecture && ( - - )} +

+ + {data.name} + {typeLabel && ( + <> + / {typeLabel} + + )} + / + + {displayName || data.id} + +

+
+ + + {isArchitecture && ( + + )} +
+
); return ( -
-
- } - namespace={data.name} - id={data.id} - version={data.version} - showVersion={false} - displayName={displayName} - typeLabel={typeLabel} - rightContent={tabs} - /> - +
+
+ {viewMenu} {comparing ? ( { }; }); +// Stub the explorer search so it doesn't instantiate real services during these +// drill-down tests. +vi.mock('../../../components/navbar/ExplorerSearch.js', () => ({ + ExplorerSearch: () =>
, +})); + vi.mock('../../../service/calm-service.js', () => ({ CalmService: vi.fn().mockImplementation(function () { return ({ fetchNamespaces: vi.fn().mockResolvedValue(['finos', 'traderx']), 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 e141e6a88..69440c9a4 100644 --- a/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx +++ b/calm-hub-ui/src/hub/components/tree-navigation/MobileNavMenu.tsx @@ -18,6 +18,7 @@ import { loadResourceForId, fetchVersionsForResource, } from './navigation-loaders.js'; +import { ExplorerSearch } from '../../../components/navbar/ExplorerSearch.js'; const RESOURCE_TYPES: TypeInUI[] = ['Architectures', 'Patterns', 'Flows', 'Standards', 'ADRs', 'Interfaces']; @@ -76,6 +77,7 @@ export function MobileNavMenu({ onDataLoad, onAdrLoad, onControlLoad, onInterfac const [domains, setDomains] = useState([]); const [leafItems, setLeafItems] = useState([]); const [loading, setLoading] = useState(false); + const [searching, setSearching] = useState(false); useEffect(() => { calmService.fetchNamespaces().then(setNamespaces).catch(() => setNamespaces([])); @@ -320,30 +322,34 @@ export function MobileNavMenu({ onDataLoad, onAdrLoad, onControlLoad, onInterfac
-
    - {loading && ( -
  • - -
  • - )} - {isEmpty && ( -
  • Nothing here
  • - )} - {!loading && - rows.map((row) => ( -
  • - + + + {!searching && ( +
      + {loading && ( +
    • +
    • - ))} -
    + )} + {isEmpty && ( +
  • Nothing here
  • + )} + {!loading && + rows.map((row) => ( +
  • + +
  • + ))} +
+ )}
); } diff --git a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx index 3edd20e5a..c69604674 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx @@ -149,7 +149,8 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport maskColor={`${THEME.colors.background}cc`} /> )} - + {/* Offset left so it clears the diagram's top-right view-options menu. */} + - + {/* Offset left so it clears the diagram's top-right view-options menu. */} + Date: Sat, 20 Jun 2026 14:37:46 +0000 Subject: [PATCH 07/17] feat(calm-hub-ui): full-screen view-options menu on mobile Open the diagram's view-options (breadcrumb + Diagram/JSON/Deployments) as a full-screen overlay on mobile, consistent with the explorer and detail panels, instead of a small dropdown. Desktop keeps the dropdown. Signed-off-by: Matthew Bain --- .../diagram-section/DiagramSection.tsx | 91 +++++++++++++------ 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx index fea520e25..ed9028662 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -42,6 +42,7 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { const activeTab: DiagramTabType = tabParam ?? 'diagram'; const isMobile = useIsMobile(); const [showTimeline, setShowTimeline] = useState(false); + const [showViewMenu, setShowViewMenu] = useState(false); const calmService = useMemo(() => new CalmService(), []); const [decorators, setDecorators] = useState([]); const [compareFrom, setCompareFrom] = useState(null); @@ -279,8 +280,66 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { `btn btn-sm justify-start gap-2 ${!comparing && activeTab === tab ? 'tab-active !bg-accent !text-white' : 'btn-ghost'}`; // The render pane is full-bleed; the breadcrumb and view-mode switch live in - // a hamburger menu pinned to the top-right of the canvas. - const viewMenu = ( + // a menu opened from a hamburger pinned to the top-right of the canvas. On + // mobile it opens as a full-screen overlay (like the explorer); on desktop + // it's a dropdown. + const breadcrumb = ( +

+ + {data.name} + {typeLabel && ( + <> + / {typeLabel} + + )} + / + + {displayName || data.id} + +

+ ); + + const renderViewModes = (onSelect?: () => void) => ( +
+ + + {isArchitecture && ( + + )} +
+ ); + + const viewMenu = isMobile ? ( + <> + + {showViewMenu && ( +
+
+ View + +
+
+ {breadcrumb} + {renderViewModes(() => setShowViewMenu(false))} +
+
+ )} + + ) : (
- - {isArchitecture && ( - - )} -
+
{breadcrumb}
+ {renderViewModes()}
); From 6ab0d266053d7d3376ac48dd182c4391948321f5 Mon Sep 17 00:00:00 2001 From: Matthew Bain Date: Sat, 20 Jun 2026 14:51:57 +0000 Subject: [PATCH 08/17] feat(calm-hub-ui): move the diagram view-options menu into the navbar The view-options hamburger no longer floats over the render pane; it is portalled into a navbar slot (#navbar-actions) so it sits in the top nav on both mobile and desktop. Falls back to inline rendering when no navbar slot is present (e.g. in isolated component tests). Signed-off-by: Matthew Bain --- calm-hub-ui/src/components/navbar/Navbar.tsx | 13 ++++++++---- .../diagram-section/DiagramSection.tsx | 21 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/calm-hub-ui/src/components/navbar/Navbar.tsx b/calm-hub-ui/src/components/navbar/Navbar.tsx index 452751acf..e55653bd1 100644 --- a/calm-hub-ui/src/components/navbar/Navbar.tsx +++ b/calm-hub-ui/src/components/navbar/Navbar.tsx @@ -33,10 +33,15 @@ export function Navbar({ onExploreClick }: NavbarProps) { />
- {/* Desktop keeps search in the navbar; on mobile it lives inside the - explorer panel instead, so it is hidden here below the lg breakpoint. */} -
- +
+ {/* Portal target for page-level actions (e.g. the diagram's + view-options menu), always visible across breakpoints. */} +
); diff --git a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx index ed9028662..2b8a14123 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { IoConstructOutline, IoGridOutline, IoEyeOutline, IoCodeOutline, IoRocketOutline, IoTimeOutline, IoCloseOutline, IoMenuOutline } from 'react-icons/io5'; import { useIsMobile } from '../../../hooks/useMediaQuery.js'; @@ -43,6 +44,12 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { const isMobile = useIsMobile(); const [showTimeline, setShowTimeline] = useState(false); const [showViewMenu, setShowViewMenu] = useState(false); + // The view-options menu trigger lives in the navbar (top nav), not floating + // over the canvas; resolve the navbar slot after mount. + const [navbarSlot, setNavbarSlot] = useState(null); + useLayoutEffect(() => { + setNavbarSlot(document.getElementById('navbar-actions')); + }, []); const calmService = useMemo(() => new CalmService(), []); const [decorators, setDecorators] = useState([]); const [compareFrom, setCompareFrom] = useState(null); @@ -319,10 +326,10 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { <> {showViewMenu && (
@@ -340,14 +347,14 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { )} ) : ( -
+
+ {navbarSlot ? createPortal(viewMenu, navbarSlot) : viewMenu}
- {viewMenu} {comparing ? ( Date: Sat, 20 Jun 2026 14:57:20 +0000 Subject: [PATCH 09/17] fix(calm-hub-ui): keep the diagram redesign mobile-only; show active view icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop keeps its original section header with inline Diagram/JSON/Deployments tabs and card chrome — the full-bleed render pane, navbar view-options menu and full-screen view overlay now apply on mobile only. The mobile navbar trigger shows the icon of the currently active view instead of a generic hamburger. Signed-off-by: Matthew Bain --- calm-hub-ui/src/hub/Hub.tsx | 2 +- .../diagram-section/DiagramSection.tsx | 179 +++++++++++------- 2 files changed, 113 insertions(+), 68 deletions(-) diff --git a/calm-hub-ui/src/hub/Hub.tsx b/calm-hub-ui/src/hub/Hub.tsx index a26eb8aef..d78249cd4 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -92,7 +92,7 @@ export default function Hub() { ) : adrData ? ( ) : isDiagramView ? ( - + ) : ( ); diff --git a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx index 2b8a14123..b98dda821 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { IoConstructOutline, IoGridOutline, IoEyeOutline, IoCodeOutline, IoRocketOutline, IoTimeOutline, IoCloseOutline, IoMenuOutline } from 'react-icons/io5'; +import { IoConstructOutline, IoGridOutline, IoEyeOutline, IoCodeOutline, IoRocketOutline, IoTimeOutline, IoCloseOutline } from 'react-icons/io5'; import { useIsMobile } from '../../../hooks/useMediaQuery.js'; import { Data } from '../../../model/calm.js'; import { sortVersionsDescending } from '../../../model/version.js'; import { JsonRenderer } from '../json-renderer/JsonRenderer.js'; import { Drawer } from '../../../visualizer/components/drawer/Drawer.js'; +import { SectionHeader } from '../section-header/SectionHeader.js'; import { DeploymentPanel } from '../../../visualizer/components/reactflow/DeploymentPanel.js'; import { CompareView } from './compare/CompareView.js'; import { diffArchitectures, diffPatterns, type NodesAndRelationshipsDiffResult } from '@finos/calm-models/diff'; @@ -27,6 +28,7 @@ import type { DeploymentDecorator, SelectedItem } from '../../../visualizer/cont interface DiagramSectionProps { data: Data & { calmType: 'Architectures' | 'Patterns' }; onItemSelect?: (item: SelectedItem) => void; + hasDetailsPanel?: boolean; } const iconMap = { @@ -36,7 +38,7 @@ const iconMap = { type DiagramTabType = 'diagram' | 'json' | 'deployments'; -export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { +export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramSectionProps) { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const tabParam = searchParams.get('tab') as DiagramTabType | null; @@ -286,10 +288,43 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) { const tabButtonClass = (tab: DiagramTabType) => `btn btn-sm justify-start gap-2 ${!comparing && activeTab === tab ? 'tab-active !bg-accent !text-white' : 'btn-ghost'}`; - // The render pane is full-bleed; the breadcrumb and view-mode switch live in - // a menu opened from a hamburger pinned to the top-right of the canvas. On - // mobile it opens as a full-screen overlay (like the explorer); on desktop - // it's a dropdown. + // Desktop: inline tabs in the section header (unchanged). + const tabs = ( +
+ + + {isArchitecture && ( + + )} +
+ ); + + // Mobile: full-bleed render pane; the view-options menu lives in the navbar + // and opens as a full-screen overlay. The trigger shows the active view icon. const breadcrumb = (

@@ -322,14 +357,17 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) {

); - const viewMenu = isMobile ? ( + const activeViewIcon = + activeTab === 'json' ? : activeTab === 'deployments' ? : ; + + const viewMenu = ( <> {showViewMenu && (
@@ -346,58 +384,81 @@ export function DiagramSection({ data, onItemSelect }: DiagramSectionProps) {
)} + ); + + const content = comparing ? ( + + ) : activeTab === 'diagram' ? ( +
+ +
+ ) : activeTab === 'deployments' && isArchitecture ? ( +
+ +
) : ( -
- -
-
{breadcrumb}
- {renderViewModes()} -
+
+
); + const timelineBar = ( + + ); + + // Desktop keeps the original layout: section header with inline tabs, card + // chrome, and an inline timeline bar. + if (!isMobile) { + return ( +
+
+ } + namespace={data.name} + id={data.id} + version={data.version} + showVersion={false} + displayName={displayName} + typeLabel={typeLabel} + rightContent={tabs} + /> +
{content}
+ {timelineBar} +
+
+ ); + } + + // Mobile: full-bleed render pane; view-options menu in the navbar; timeline + // tucked behind a floating button. return (
{navbarSlot ? createPortal(viewMenu, navbarSlot) : viewMenu}
- {comparing ? ( - - ) : activeTab === 'diagram' ? ( -
- -
- ) : activeTab === 'deployments' && isArchitecture ? ( -
- -
- ) : ( -
- -
- )} + {content} - {/* Mobile: timeline is tucked behind a floating button so it - doesn't permanently occupy the short viewport. */} - {isMobile && !showTimeline && ( + {!showTimeline && (
- - {!isMobile && ( - - )}
- {isMobile && showTimeline && ( + {showTimeline && (
)} @@ -449,9 +483,10 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramS ); } - // Mobile: full-bleed render pane; view-options menu in the navbar; timeline - // tucked behind a floating button. + // Mobile: full-bleed render pane; view-options menu (with component search) + // in the navbar; timeline tucked behind a floating button. return ( +
{navbarSlot ? createPortal(viewMenu, navbarSlot) : viewMenu}
@@ -509,5 +544,6 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramS
)}
+
); } diff --git a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx index c69604674..b54bc4fa3 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import ReactFlow, { Node, Background, @@ -22,6 +22,7 @@ import { getMatchingNodeIds, isEdgeVisible, getUniqueNodeTypes } from './utils/s import { useGraphInteractions } from './hooks/useGraphInteractions.js'; import { applyStoredPositions } from '../../services/node-position-service.js'; import { useIsMobile } from '../../../hooks/useMediaQuery.js'; +import { useNodeSearch } from './node-search-context.js'; import type { ArchitectureGraphProps } from '../../contracts/contracts.js'; const edgeTypes = { custom: FloatingEdge }; @@ -38,15 +39,14 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport const [nodes, setNodes, onNodesChangeBase] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [typeFilter, setTypeFilter] = useState(''); + const { searchTerm, setSearchTerm, typeFilter, setTypeFilter, availableNodeTypes, setAvailableNodeTypes, external: externalSearch } = + useNodeSearch(); // Ref holds the structural node data from parsing. // Filter effect reads from this instead of reactive state to avoid // re-triggering when setNodes/setEdges update styles. const sourceNodesRef = useRef([]); - const [availableNodeTypes, setAvailableNodeTypes] = useState([]); const isMobile = useIsMobile(); const { @@ -72,7 +72,7 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport setNodes(viewportKey ? applyStoredPositions(viewportKey, parsedNodes) : parsedNodes); setEdges(parsedEdges); setAvailableNodeTypes(getUniqueNodeTypes(parsedNodes)); - }, [jsonData, setNodes, setEdges, onNodeClick, viewportKey]); + }, [jsonData, setNodes, setEdges, setAvailableNodeTypes, onNodeClick, viewportKey]); // Search & filter const isSearchActive = searchTerm !== '' || typeFilter !== ''; @@ -149,16 +149,17 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport maskColor={`${THEME.colors.background}cc`} /> )} - {/* Offset left so it clears the diagram's top-right view-options menu. */} - - - + {!externalSearch && ( + + + + )}
); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx b/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx index a34d8fe60..c6b5c0b06 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx @@ -24,6 +24,7 @@ import { getMatchingNodeIds, isEdgeVisible, getUniqueNodeTypes } from './utils/s import { useGraphInteractions } from './hooks/useGraphInteractions.js'; import { applyStoredPositions } from '../../services/node-position-service.js'; import { useIsMobile } from '../../../hooks/useMediaQuery.js'; +import { useNodeSearch } from './node-search-context.js'; import { DecisionSelectorPanel } from './DecisionSelectorPanel.js'; import { extractDecisionPoints, @@ -56,8 +57,8 @@ export function PatternGraph({ patternData, onNodeClick, onEdgeClick, viewportKe const [nodes, setNodes, onNodesChangeBase] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [typeFilter, setTypeFilter] = useState(''); + const { searchTerm, setSearchTerm, typeFilter, setTypeFilter, availableNodeTypes, setAvailableNodeTypes, external: externalSearch } = + useNodeSearch(); const [decisionSelections, setDecisionSelections] = useState(new Map()); // Refs hold the structural node/edge data from parsing. @@ -66,7 +67,6 @@ export function PatternGraph({ patternData, onNodeClick, onEdgeClick, viewportKe const sourceNodesRef = useRef([]); const sourceEdgesRef = useRef([]); - const [availableNodeTypes, setAvailableNodeTypes] = useState([]); const [decisionPoints, setDecisionPoints] = useState>([]); const isMobile = useIsMobile(); @@ -95,7 +95,7 @@ export function PatternGraph({ patternData, onNodeClick, onEdgeClick, viewportKe setEdges(parsedEdges); setAvailableNodeTypes(getUniqueNodeTypes(parsedNodes)); setDecisionPoints(extractDecisionPoints(parsedNodes)); - }, [patternData, setNodes, setEdges, viewportKey]); + }, [patternData, setNodes, setEdges, setAvailableNodeTypes, viewportKey]); // Search & filter const isSearchActive = searchTerm !== '' || typeFilter !== ''; @@ -221,8 +221,8 @@ export function PatternGraph({ patternData, onNodeClick, onEdgeClick, viewportKe onReset={handleDecisionReset} /> - {/* Offset left so it clears the diagram's top-right view-options menu. */} - + {!externalSearch && ( + + )}
); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx b/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx index e988b1f46..24c26a1f2 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx @@ -9,6 +9,8 @@ interface SearchBarProps { typeFilter: string; onTypeFilterChange: (type: string) => void; nodeTypes: string[]; + /** Render the full bar (no collapse-to-icon), e.g. inside the mobile view menu. */ + forceExpanded?: boolean; } const iconButtonStyle: React.CSSProperties = { @@ -31,14 +33,16 @@ export function SearchBar({ typeFilter, onTypeFilterChange, nodeTypes, + forceExpanded = false, }: SearchBarProps) { const isMobile = useIsMobile(); const [expanded, setExpanded] = useState(false); - // On small screens the full search/filter bar steals too much canvas, so it + // On small screens the floating canvas bar steals too much room, so it // collapses to a single icon button until tapped. An active search/filter // keeps it expanded so the user can see and clear what's applied. - const showCompact = isMobile && !expanded && !searchTerm && !typeFilter; + // forceExpanded (e.g. inside the view menu) always shows the full bar. + const showCompact = isMobile && !expanded && !searchTerm && !typeFilter && !forceExpanded; if (showCompact) { return ( @@ -63,7 +67,8 @@ export function SearchBar({ border: `1px solid ${THEME.colors.border}`, borderRadius: '8px', padding: '6px 10px', - boxShadow: THEME.shadows.md, + boxShadow: forceExpanded ? 'none' : THEME.shadows.md, + width: forceExpanded ? '100%' : undefined, }} > @@ -78,8 +83,10 @@ export function SearchBar({ outline: 'none', background: 'transparent', color: THEME.colors.foreground, - fontSize: '12px', - width: isMobile ? '110px' : '140px', + fontSize: forceExpanded ? '14px' : '12px', + minWidth: 0, + flex: forceExpanded ? 1 : undefined, + width: forceExpanded ? 'auto' : isMobile ? '110px' : '140px', }} /> {searchTerm && ( @@ -122,7 +129,7 @@ export function SearchBar({ ))} )} - {isMobile && ( + {isMobile && !forceExpanded && ( )} - +
+ -
+
{/* Portal target for page-level actions (e.g. the diagram's view-options menu), always visible across breakpoints. */}