diff --git a/calm-hub-ui/AGENTS.md b/calm-hub-ui/AGENTS.md index 6b26ff7ce..00862bb8e 100644 --- a/calm-hub-ui/AGENTS.md +++ b/calm-hub-ui/AGENTS.md @@ -160,9 +160,77 @@ Instead, place `HeaderSection` and `BodySection` in their own files and import t - Use `useCallback` for functions referenced in `useEffect` dependency arrays - **Never suppress** `react-hooks/exhaustive-deps` — fix the dependency array properly +## Responsive Design + +The CALM Hub UI is **mobile responsive** — it must be usable on phones as well as +desktops. Both renders are first-class; a change is not complete until it has been +verified at **both** a desktop and a mobile viewport. + +### The `useIsMobile` hook + +Layout decisions key off `src/hooks/useMediaQuery.ts`: + +- `useMediaQuery(query)` — subscribes to a CSS media query (test-safe: returns + `false` when `window.matchMedia` is absent, so components fall back to desktop). +- `useIsMobile()` — `useMediaQuery('(max-width: 1023px)')`. The breakpoint is + Tailwind's `lg` (1024px): anything narrower is treated as mobile. + +```typescript +const isMobile = useIsMobile(); +if (!isMobile) { + return ; // early-return keeps desktop untouched +} +return ; +``` + +### Mobile-only changes must not alter desktop + +When adding or reworking mobile behaviour, **branch on `isMobile` and keep the +desktop path byte-for-byte unchanged** (an early `return` for the desktop layout is +the clearest way). Reviewers have repeatedly rejected diffs that "accidentally" +restyled desktop while targeting mobile — don't. + +### Established mobile patterns + +- **Full-screen push overlays**, not float-overs: menus, the view-options menu, the + timeline, and detail panels take over the viewport (`fixed inset-0`, + `animate-slide-in-right`) rather than floating above the canvas where iOS chrome + would hide them. +- **Navbar-hosted actions**: page-level controls (e.g. the diagram view-options + menu) portal into the navbar `#navbar-actions` slot instead of floating in the + render pane. See `components/navbar/Navbar.tsx` and `diagram-section/DiagramSection.tsx`. +- **iOS-style drill-down explorer** on mobile (`tree-navigation/MobileNavMenu.tsx`): + one flat list per level, not a tree. +- **Full-bleed render pane** with the minimap/zoom controls hidden (pinch is the + native gesture) — see `visualizer/components/reactflow/`. +- **Tabbed detail views** where desktop stacks panels (e.g. + `control-detail-section/ControlDetailSection.tsx` shows Requirement/Configuration + as tabs on mobile). +- **Device safe areas**: `viewport-fit=cover` in `index.html` plus + `env(safe-area-inset-*)` so the navbar clears the notch (`navbar/Navbar.css`). + +Reference components for the patterns above: `hub/Hub.tsx`, `components/navbar/Navbar.tsx`, +`hub/components/diagram-section/DiagramSection.tsx`, +`hub/components/tree-navigation/MobileNavMenu.tsx`, and +`hub/components/control-detail-section/ControlDetailSection.tsx`. + ## Testing - Unit tests use Vitest + React Testing Library - All new components and services must have tests - Mock services at the module level with `vi.mock` and class constructor patterns - E2E tests are in `cypress/` + +### Test both desktop and mobile + +Any change that touches layout/rendering **must be verified at both a desktop and a +mobile viewport** — never just one. + +- **Unit tests**: components that branch on `useIsMobile()` need cases for both + renders. Drive the breakpoint by mocking `window.matchMedia` (returning + `matches: true` simulates mobile, `false` desktop). `ControlDetailSection.test.tsx` + and `hub/Hub.test.tsx` show the `mockViewport`/`mockMobileViewport` helper pattern. + The default (no mock) reports desktop, so add explicit mobile cases. +- **Manual/visual check**: run `npm run start --workspace calm-hub-ui` and confirm the + change at a desktop width and at a mobile width (≤1023px — e.g. a 390px-wide device + in browser dev-tools). Confirm desktop is unchanged when the work was mobile-only. diff --git a/calm-hub-ui/index.html b/calm-hub-ui/index.html index 0ebf5a9ef..6bfeb255e 100644 --- a/calm-hub-ui/index.html +++ b/calm-hub-ui/index.html @@ -3,7 +3,7 @@ - + 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..dfb53b452 --- /dev/null +++ b/calm-hub-ui/src/components/navbar/ExplorerSearch.tsx @@ -0,0 +1,319 @@ +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'; +import { pickLatestVersion } from '../../model/version.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 = useMemo(() => (results ? flattenResults(results) : []), [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}`); + } + const latest = pickLatestVersion((versions ?? []).map(String)); + if (!latest) throw new Error('No versions found'); + return latest; + }, + [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 handleClear = useCallback(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + setQuery(''); + setResults(null); + setSelectedIndex(-1); + setError(false); + inputRef.current?.focus(); + }, []); + + 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(); + } + }, + [active, flatResults, selectedIndex, navigateToResult, handleClear] + ); + + 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/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/components/navbar/Navbar.css b/calm-hub-ui/src/components/navbar/Navbar.css index 4184201e1..1680b7eec 100644 --- a/calm-hub-ui/src/components/navbar/Navbar.css +++ b/calm-hub-ui/src/components/navbar/Navbar.css @@ -1,4 +1,15 @@ .logo { - max-width: unset; + /* Allow the logo to shrink within the navbar instead of forcing the bar + wider than the viewport on narrow screens. */ + max-width: 100%; height: inherit; } + +/* Respect device safe areas (notch / rounded corners) and never let the bar + exceed the viewport width. */ +.navbar { + padding-top: max(0.5rem, env(safe-area-inset-top)); + padding-left: max(0.5rem, env(safe-area-inset-left)); + padding-right: max(0.5rem, env(safe-area-inset-right)); + max-width: 100vw; +} diff --git a/calm-hub-ui/src/components/navbar/Navbar.tsx b/calm-hub-ui/src/components/navbar/Navbar.tsx index 0980ae232..ae771c000 100644 --- a/calm-hub-ui/src/components/navbar/Navbar.tsx +++ b/calm-hub-ui/src/components/navbar/Navbar.tsx @@ -1,64 +1,54 @@ import './Navbar.css'; -import { NavLink } from 'react-router-dom'; +import { Link, 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 + + )} +
+
+ CALM Logo - -
-
    -
  • - - Hub - -
  • -
  • - - Visualizer - -
  • -
-
+
-
- +
+ {/* 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/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/diff/components/DiffGraph.tsx b/calm-hub-ui/src/diff/components/DiffGraph.tsx index e35120463..e9b0e0028 100644 --- a/calm-hub-ui/src/diff/components/DiffGraph.tsx +++ b/calm-hub-ui/src/diff/components/DiffGraph.tsx @@ -18,6 +18,7 @@ import { DecisionGroupNode } from '../../visualizer/components/reactflow/Decisio import { THEME } from '../../visualizer/components/reactflow/theme.js'; import { parseCALMDataWithDiff } from './utils/diffTransformer.js'; import { parsePatternDataWithDiff } from './utils/patternDiffTransformer.js'; +import { useIsMobile } from '../../hooks/useMediaQuery.js'; import type { DiffGraphProps } from '../model/diff-ui-types.js'; const edgeTypes = { custom: FloatingEdge }; @@ -30,6 +31,7 @@ function DiffGraphInner({ source, sourceType, diffResult, isFirst }: DiffGraphPr const [edges, setEdges, onEdgesChange] = useEdgesState([]); const { fitView } = useReactFlow(); const nodesInitialized = useNodesInitialized(); + const isMobile = useIsMobile(); const containerRef = useRef(null); const fitFrameRef = useRef(); @@ -86,21 +88,25 @@ function DiffGraphInner({ source, sourceType, diffResult, isFirst }: DiffGraphPr style={{ background: THEME.colors.background }} > - - + {!isMobile && ( + + )} + {!isMobile && ( + + )}
); 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..b02a1747e --- /dev/null +++ b/calm-hub-ui/src/hooks/useMediaQuery.ts @@ -0,0 +1,50 @@ +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(); + + // Older browsers (notably Safari < 14) only implement the deprecated + // addListener/removeListener API, so fall back to it when the modern + // addEventListener is unavailable. + if (typeof mediaQueryList.addEventListener === 'function') { + mediaQueryList.addEventListener('change', handleChange); + return () => mediaQueryList.removeEventListener('change', handleChange); + } + mediaQueryList.addListener(handleChange); + return () => mediaQueryList.removeListener(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..d25f78ca9 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: ({ @@ -69,6 +90,35 @@ vi.mock('./components/tree-navigation/TreeNavigation', () => ({ ), })); +vi.mock('./components/tree-navigation/MobileNavMenu', () => ({ + MobileNavMenu: ({ + onDataLoad, + onClose, + }: { + onDataLoad: (data: unknown) => void; + onClose: () => void; + }) => ( +
+ + +
+ ), +})); + vi.mock('./components/json-renderer/JsonRenderer', () => ({ JsonRenderer: ({ json }: { json: unknown }) => (
{json ? 'JSON' : ''}
@@ -94,7 +144,16 @@ vi.mock('./components/interface-detail-section/InterfaceDetailSection', () => ({ })); vi.mock('../components/navbar/Navbar', () => ({ - Navbar: () => , + Navbar: ({ onExploreClick }: { onExploreClick?: () => void }) => ( + + ), })); vi.mock('./components/diagram-section/DiagramSection', () => ({ @@ -267,5 +326,76 @@ 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', () => { + 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('keeps the drill-down menu mounted off-canvas with a menu button by default', () => { + const restore = mockMobileViewport(true); + renderWithRouter(); + + // 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('Toggle explorer')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + restore(); + }); + + it('opens the full-screen drill-down panel when the menu button is clicked', () => { + const restore = mockMobileViewport(true); + renderWithRouter(); + + 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(); + + restore(); + }); + + it('closes the panel after a resource is loaded', () => { + const restore = mockMobileViewport(true); + renderWithRouter(); + + fireEvent.click(screen.getByLabelText('Toggle explorer')); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + 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('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 9ffd0434e..d78249cd4 100644 --- a/calm-hub-ui/src/hub/Hub.tsx +++ b/calm-hub-ui/src/hub/Hub.tsx @@ -1,6 +1,8 @@ import { useCallback, useMemo, useState } from 'react'; 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'; import { Data, Adr } from '../model/calm.js'; import { ControlData } from '../model/control.js'; import { InterfaceData } from '../model/interface.js'; @@ -20,7 +22,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 +32,7 @@ export default function Hub() { setControlData(undefined); setInterfaceData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } function handleAdrLoad(adr: Adr) { @@ -36,6 +41,7 @@ export default function Hub() { setControlData(undefined); setInterfaceData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } function handleControlLoad(control: ControlData) { @@ -44,6 +50,7 @@ export default function Hub() { setAdrData(undefined); setInterfaceData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } function handleInterfaceLoad(iface: InterfaceData) { @@ -52,6 +59,7 @@ export default function Hub() { setAdrData(undefined); setControlData(undefined); setSelectedItem(null); + setIsMobileNavOpen(false); } const handleItemSelect = useCallback((item: SelectedItem) => { @@ -67,44 +75,92 @@ export default function Hub() { const memoizedDataLoad = useMemo(() => handleDataLoad, []); const memoizedAdrLoad = useMemo(() => handleAdrLoad, []); + const treeNavigation = ( + setIsSidebarOpen(false)} + /> + ); + + const detailContent = interfaceData ? ( + + ) : controlData ? ( + + ) : adrData ? ( + + ) : isDiagramView ? ( + + ) : ( + + ); + return (
- -
-
-
- {isSidebarOpen ? ( -
- setIsSidebarOpen(false)} /> -
- ) : ( -
- -
- )} + (isMobile ? setIsMobileNavOpen(true) : setIsSidebarOpen((v) => !v))} /> +
+ {/* Desktop: inline, collapsible tree-navigation column */} + {!isMobile && ( +
+
+ {isSidebarOpen ? ( +
{treeNavigation}
+ ) : ( +
+ +
+ )} +
+ )} + + {/* 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 && ( +
+
+ setIsMobileNavOpen(false)} + /> +
+
+ )} + +
+
{detailContent}
-
- {interfaceData ? ( - - ) : controlData ? ( - - ) : adrData ? ( - - ) : isDiagramView ? ( - - ) : ( - - )} -
+ {selectedItem && isDiagramView && ( - + isMobile ? ( +
+ +
+ ) : ( + + ) )}
diff --git a/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.test.tsx b/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.test.tsx index 549ae77e9..a5e2d854d 100644 --- a/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.test.tsx +++ b/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ControlDetailSection } from './ControlDetailSection.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ControlData } from '../../../model/control.js'; // ── Mocks ───────────────────────────────────────────────── @@ -34,6 +34,27 @@ vi.mock('../../../service/control-service.js', () => ({ }; }), })); +/** + * Force `useIsMobile()` (which reads window.matchMedia) to report the given + * viewport. Returns a restore function. + */ +function mockViewport(isMobile: boolean) { + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: isMobile, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; + return () => { + window.matchMedia = original; + }; +} + // ── Test data ───────────────────────────────────────────── const controlData: ControlData = { @@ -448,6 +469,81 @@ describe('ControlDetailSection', () => { }); }); + // ────────────────────────────────────────────────── + // Mobile tabbed layout + // ────────────────────────────────────────────────── + describe('mobile layout', () => { + let restore: () => void; + + beforeEach(() => { + restore = mockViewport(true); + }); + + afterEach(() => { + restore(); + }); + + it('renders Requirement and Configuration tabs instead of stacked panels', async () => { + setupMocks(); + render(); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Requirement' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Configuration' })).toBeInTheDocument(); + }); + + // Only the active (requirement) panel is shown, so just one readable view. + expect(screen.getAllByTestId('readable-json-view')).toHaveLength(1); + }); + + it('shows the requirement panel by default', async () => { + setupMocks({ reqSchema: requirementSchema }); + render(); + + await waitFor(() => { + const view = screen.getByTestId('readable-json-view'); + expect(view).toHaveTextContent(JSON.stringify(requirementSchema)); + }); + }); + + it('omits the Configuration tab when no configurations exist', async () => { + setupMocks({ configIds: [] }); + render(); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Requirement' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('tab', { name: 'Configuration' })).not.toBeInTheDocument(); + }); + + it('switches to the configuration panel and shows its config tabs', async () => { + setupMocks({ configIds: [10, 20] }); + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('tab', { name: 'Configuration' })); + + expect(screen.getByRole('tab', { name: 'Config 10' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Config 20' })).toBeInTheDocument(); + // Requirement version pickers are not visible on the configuration panel. + expect(screen.queryByText('Requirement /')).not.toBeInTheDocument(); + }); + + it('applies the active style to the selected panel tab', async () => { + setupMocks(); + const user = userEvent.setup(); + render(); + + const reqTab = await screen.findByRole('tab', { name: 'Requirement' }); + expect(reqTab).toHaveClass('tab-active'); + + await user.click(screen.getByRole('tab', { name: 'Configuration' })); + + expect(screen.getByRole('tab', { name: 'Configuration' })).toHaveClass('tab-active'); + expect(screen.getByRole('tab', { name: 'Requirement' })).not.toHaveClass('tab-active'); + }); + }); + // ────────────────────────────────────────────────── // State reset on prop change // ────────────────────────────────────────────────── diff --git a/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.tsx b/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.tsx index 0f1547e1e..92672004d 100644 --- a/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.tsx +++ b/calm-hub-ui/src/hub/components/control-detail-section/ControlDetailSection.tsx @@ -4,8 +4,10 @@ import { ControlData } from '../../../model/control.js'; import { JsonRenderer } from '../json-renderer/JsonRenderer.js'; import { ReadableJsonView } from './ReadableJsonView.js'; import { ControlService } from '../../../service/control-service.js'; +import { useIsMobile } from '../../../hooks/useMediaQuery.js'; type ViewMode = 'readable' | 'raw'; +type ControlPanel = 'requirement' | 'configuration'; interface ControlDetailSectionProps { controlData: ControlData; @@ -13,6 +15,7 @@ interface ControlDetailSectionProps { export function ControlDetailSection({ controlData }: ControlDetailSectionProps) { const controlService = useMemo(() => new ControlService(), []); + const isMobile = useIsMobile(); // Requirement state const [requirementVersions, setRequirementVersions] = useState([]); @@ -30,6 +33,10 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps) const [reqViewMode, setReqViewMode] = useState('readable'); const [cfgViewMode, setCfgViewMode] = useState('readable'); + // On mobile the requirement and configuration panels are shown as tabs + // rather than stacked, so we track which one is active. + const [activePanel, setActivePanel] = useState('requirement'); + const handleReqVersionClick = useCallback((version: string) => { setSelectedReqVersion(version); controlService.fetchRequirementForVersion( @@ -51,6 +58,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps) setConfigVersions([]); setSelectedConfigVersion(''); setConfigJson(undefined); + setActivePanel('requirement'); controlService.fetchRequirementVersions( controlData.domain, @@ -93,6 +101,165 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps) }); }; + // ── Shared builders (used by both the desktop stacked layout and the + // mobile tabbed layout) so role/name selectors stay identical. ───────── + + const viewToggle = (mode: ViewMode, setMode: (m: ViewMode) => void) => ( +
+ + +
+ ); + + const reqVersionButtons = requirementVersions.map((v) => ( + + )); + + const configIdButtons = configIds.map((cid) => ( + + )); + + const configVersionButtons = configVersions.map((v) => ( + + )); + + const requirementContent = reqViewMode === 'readable' ? ( + + ) : ( + + ); + + const configurationContent = cfgViewMode === 'readable' ? ( + + ) : ( + + ); + + // ── Mobile: a single full-bleed pane with Requirement / Configuration + // tabs, stacked headers, and horizontally scrollable version pickers. ── + if (isMobile) { + const showConfig = configIds.length > 0; + const panel = activePanel === 'configuration' && showConfig ? 'configuration' : 'requirement'; + + return ( +
+ {/* Control name */} +
+

+ + {controlData.controlName} +

+
+ + {/* Requirement / Configuration tabs */} +
+ + {showConfig && ( + + )} +
+ + {panel === 'requirement' ? ( +
+ {/* Breadcrumb + view toggle */} +
+

+ Requirement{selectedReqVersion ? ` / ${selectedReqVersion}` : ''} +

+ {viewToggle(reqViewMode, setReqViewMode)} +
+ {requirementVersions.length > 1 && ( +
+
+ {reqVersionButtons} +
+
+ )} +
+ {requirementContent} +
+
+ ) : ( +
+ {/* Breadcrumb + view toggle */} +
+

+ Configurations + {selectedConfigId !== null ? ` / ${selectedConfigId}` : ''} + {selectedConfigVersion ? ` / ${selectedConfigVersion}` : ''} +

+ {viewToggle(cfgViewMode, setCfgViewMode)} +
+
+
+
+ {configIdButtons} +
+ {selectedConfigId !== null && configVersions.length > 0 && ( + <> + / +
+ {configVersionButtons} +
+ + )} +
+
+
+ {configurationContent} +
+
+ )} +
+ ); + } + + // ── Desktop: the original two stacked panels (Requirement / Configurations). ── return (
{/* Top section: Requirement */} @@ -112,49 +279,21 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps) )} {/* Readable / Raw toggle */} -
- - -
+ {viewToggle(reqViewMode, setReqViewMode)}
{/* Requirement version tabs */} {requirementVersions.length > 1 && (
- {requirementVersions.map((v) => ( - - ))} + {reqVersionButtons}
)} {/* Requirement content */}
- {reqViewMode === 'readable' ? ( - - ) : ( - - )} + {requirementContent}
@@ -182,38 +321,14 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps) )} {/* Readable / Raw toggle */} -
- - -
+ {viewToggle(cfgViewMode, setCfgViewMode)}
{/* Configuration breadcrumb navigation */}
{/* Config ID tabs */}
- {configIds.map((cid) => ( - - ))} + {configIdButtons}
{/* Config version tabs (shown when a config is selected) */} @@ -221,16 +336,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps) <> /
- {configVersions.map((v) => ( - - ))} + {configVersionButtons}
)} @@ -238,11 +344,7 @@ export function ControlDetailSection({ controlData }: ControlDetailSectionProps) {/* Configuration content */}
- {cfgViewMode === 'readable' ? ( - - ) : ( - - )} + {configurationContent}
)} 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..032616e95 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -1,17 +1,22 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; 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, IoCheckmarkOutline } 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 { SearchBar } from '../../../visualizer/components/reactflow/SearchBar.js'; +import { NodeSearchProvider, type NodeSearchState } from '../../../visualizer/components/reactflow/node-search-context.js'; import { CompareView } from './compare/CompareView.js'; import { diffArchitectures, diffPatterns, type NodesAndRelationshipsDiffResult } from '@finos/calm-models/diff'; import type { CalmArchitectureSchema } from '@finos/calm-models/types'; import type { DiffSource } from '../../../diff/model/diff-ui-types.js'; import { TimelineBar, type TimelineMoment } from './timeline/TimelineBar.js'; +import { MobileTimeline } from './timeline/MobileTimeline.js'; import { currentMomentIdFromTimeline, isExplicitTimeline, @@ -41,6 +46,32 @@ 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 [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')); + }, []); + // Node ("component") search state, surfaced inside the mobile view menu and + // applied to the graph via NodeSearchProvider. + const [nodeSearchTerm, setNodeSearchTerm] = useState(''); + const [nodeTypeFilter, setNodeTypeFilter] = useState(''); + const [nodeTypes, setNodeTypes] = useState([]); + const nodeSearch = useMemo( + () => ({ + searchTerm: nodeSearchTerm, + setSearchTerm: setNodeSearchTerm, + typeFilter: nodeTypeFilter, + setTypeFilter: setNodeTypeFilter, + availableNodeTypes: nodeTypes, + setAvailableNodeTypes: setNodeTypes, + external: true, + }), + [nodeSearchTerm, nodeTypeFilter, nodeTypes] + ); const calmService = useMemo(() => new CalmService(), []); const [decorators, setDecorators] = useState([]); const [compareFrom, setCompareFrom] = useState(null); @@ -274,91 +305,242 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel }: DiagramS const Icon = iconMap[data.calmType]; const comparing = compareFrom !== null && compareTo !== null; + // Desktop: inline tabs in the section header (unchanged). const tabs = (
{isArchitecture && ( )}
); - return ( -
-
- } - namespace={data.name} - id={data.id} - version={data.version} - showVersion={false} - displayName={displayName} - typeLabel={typeLabel} - rightContent={tabs} - /> - -
- {comparing ? ( - - ) : activeTab === 'diagram' ? ( -
- -
- ) : activeTab === 'deployments' && isArchitecture ? ( -
- -
- ) : ( -
- + // Mobile: full-bleed render pane; the view-options menu lives in the navbar + // and opens as a full-screen overlay styled like the explorer. The trigger + // shows the active view icon. + const breadcrumb = ( +

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

+ ); + + const viewModeRow = (tab: DiagramTabType, icon: ReactNode, label: string, onSelect?: () => void) => { + const active = !comparing && activeTab === tab; + return ( + + ); + }; + + const renderViewModes = (onSelect?: () => void) => ( +
+ {viewModeRow('diagram', , 'Diagram', onSelect)} + {viewModeRow('json', , 'JSON', onSelect)} + {isArchitecture && viewModeRow('deployments', , 'Deployments', onSelect)} +
+ ); + + const activeViewIcon = + activeTab === 'json' ? : activeTab === 'deployments' ? : ; + + const viewMenu = ( + <> + + {showViewMenu && ( +
+
+ View + +
+
+ {breadcrumb} + {renderViewModes(() => setShowViewMenu(false))} +
+
- )} + {!comparing && activeTab === 'diagram' && ( +
+
+ Search components +
+ +
+ )} +
+ )} + + ); + + const content = comparing ? ( + + ) : activeTab === 'diagram' ? ( +
+ +
+ ) : activeTab === 'deployments' && isArchitecture ? ( +
+ +
+ ) : ( +
+ +
+ ); + + 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; the view-options menu (which now also + // contains the component search and the timeline action) lives in the navbar + // right-hand actions — nothing floats over the canvas where iOS chrome would + // hide it. + return ( + +
+ {navbarSlot ? createPortal(viewMenu, navbarSlot) : viewMenu} +
+
+ {content} +
+
+ + {showTimeline && ( +
+ setShowTimeline(false)} + /> +
+ )}
+
); } diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/MobileTimeline.test.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/MobileTimeline.test.tsx new file mode 100644 index 000000000..6f64988ba --- /dev/null +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/MobileTimeline.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { MobileTimeline } from './MobileTimeline.js'; +import type { TimelineMoment } from './TimelineBar.js'; + +const moments: TimelineMoment[] = [ + { key: 'm1', label: '1.0.0', version: '1.0.0', validFrom: '2025-01-01' }, + { key: 'm2', label: '1.5.0', version: '1.5.0', validFrom: '2025-06-01' }, + { key: 'm3', label: '2.0.0', version: '2.0.0', validFrom: '2025-09-01' }, +]; + +function renderTimeline(overrides?: Partial>) { + const onNavigate = overrides?.onNavigate ?? vi.fn(); + const onClose = overrides?.onClose ?? vi.fn(); + const result = render( + []} + onNavigate={onNavigate} + onClose={onClose} + {...overrides} + /> + ); + return { onNavigate, onClose, ...result }; +} + +describe('MobileTimeline', () => { + it('shows the currently-viewed moment with a forward chevron when more moments exist', () => { + renderTimeline(); + const current = screen.getByTestId('mobile-timeline-current'); + expect(within(current).getByText('1.5.0')).toBeInTheDocument(); + // The chevron row is enabled (clickable) because there is more than one moment. + expect(current).toBeEnabled(); + expect(within(current).getByText('3')).toBeInTheDocument(); + }); + + it('does not offer the drill-down when only one moment exists', () => { + renderTimeline({ moments: [moments[0]], currentVersion: '1.0.0' }); + expect(screen.getByTestId('mobile-timeline-current')).toBeDisabled(); + }); + + it('pushes the all-versions list (newest first) when the current moment is tapped', () => { + renderTimeline(); + fireEvent.click(screen.getByTestId('mobile-timeline-current')); + + const list = screen.getByTestId('mobile-timeline-list'); + const rows = within(list).getAllByRole('button'); + // Newest first: 2.0.0, 1.5.0, 1.0.0. + expect(rows[0]).toHaveTextContent('2.0.0'); + expect(rows[2]).toHaveTextContent('1.0.0'); + expect(screen.getByText('All versions')).toBeInTheDocument(); + }); + + it('marks the currently-viewed version as current in the list', () => { + renderTimeline(); + fireEvent.click(screen.getByTestId('mobile-timeline-current')); + expect(screen.getByLabelText('Select version 1.5.0')).toHaveAttribute('aria-current', 'true'); + }); + + it('navigates to a version and returns to the detail level when a row is selected', () => { + const { onNavigate } = renderTimeline(); + fireEvent.click(screen.getByTestId('mobile-timeline-current')); + fireEvent.click(screen.getByLabelText('Select version 2.0.0')); + + expect(onNavigate).toHaveBeenCalledWith('2.0.0'); + // Popped back to the detail level. + expect(screen.queryByTestId('mobile-timeline-list')).not.toBeInTheDocument(); + expect(screen.getByTestId('mobile-timeline-current')).toBeInTheDocument(); + }); + + it('shows the NOW badge against the timeline current-moment on an explicit timeline', () => { + renderTimeline({ timelineIsExplicit: true, timelineCurrentMomentId: 'm2' }); + expect(screen.getByText('NOW')).toBeInTheDocument(); + }); + + it('omits the NOW badge for an implied (non-explicit) timeline', () => { + renderTimeline({ timelineIsExplicit: false, timelineCurrentMomentId: 'm2' }); + expect(screen.queryByText('NOW')).not.toBeInTheDocument(); + }); + + it('closes the sheet when the close button is pressed', () => { + const { onClose } = renderTimeline(); + fireEvent.click(screen.getByLabelText('Close timeline')); + expect(onClose).toHaveBeenCalled(); + }); + + it('goes back from the list to the detail level', () => { + renderTimeline(); + fireEvent.click(screen.getByTestId('mobile-timeline-current')); + expect(screen.getByTestId('mobile-timeline-list')).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText('Back')); + expect(screen.queryByTestId('mobile-timeline-list')).not.toBeInTheDocument(); + }); +}); diff --git a/calm-hub-ui/src/hub/components/diagram-section/timeline/MobileTimeline.tsx b/calm-hub-ui/src/hub/components/diagram-section/timeline/MobileTimeline.tsx new file mode 100644 index 000000000..9f1d17558 --- /dev/null +++ b/calm-hub-ui/src/hub/components/diagram-section/timeline/MobileTimeline.tsx @@ -0,0 +1,201 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + IoChevronBackOutline, + IoChevronForwardOutline, + IoCheckmarkOutline, + IoCloseOutline, + IoTimeOutline, +} from 'react-icons/io5'; +import type { TimelineMoment } from './TimelineBar.js'; +import { VersionDetail } from './VersionDetail.js'; +import type { VersionChange } from './perVersionChanges.js'; + +interface MobileTimelineProps { + /** Moments in chronological order (oldest first, newest last). */ + moments: TimelineMoment[]; + /** The version currently being viewed in the main area. */ + currentVersion: string; + /** Resource display name — shown in the all-versions sub-header. */ + displayName?: string; + /** unique-id of the timeline's current-moment — drives the NOW badge. */ + timelineCurrentMomentId?: string; + /** Whether the timeline is explicit (drives whether the NOW badge shows at all). */ + timelineIsExplicit?: boolean; + /** + * Compute the changes between two versions for the detail panel's WHAT + * CHANGED section. Returns [] when no predecessor exists. + */ + loadChangesForVersion?: (prevVersion: string, currVersion: string) => Promise; + /** Navigate the main area to a moment's version. */ + onNavigate: (version: string) => void; + /** Dismiss the whole timeline sheet. */ + onClose: () => void; +} + +/** The drill-down level currently shown. */ +type Level = 'detail' | 'moments'; + +/** + * Mobile timeline as an iOS-style drill-down, replacing the desktop's + * horizontally-scrolling moment cards (which are unusable on a narrow, + * touch-only screen). The first level shows the currently-viewed moment and its + * detail; when more moments exist, a forward chevron pushes a flat list of all + * versions. Tapping a version navigates the diagram to it and pops back to the + * detail level — mirroring the {@link MobileNavMenu} explorer pattern. + */ +export function MobileTimeline({ + moments, + currentVersion, + displayName, + timelineCurrentMomentId, + timelineIsExplicit = false, + loadChangesForVersion, + onNavigate, + onClose, +}: MobileTimelineProps) { + const [level, setLevel] = useState('detail'); + + const isNow = useCallback( + (moment: TimelineMoment) => + timelineIsExplicit && !!timelineCurrentMomentId && moment.key === timelineCurrentMomentId, + [timelineIsExplicit, timelineCurrentMomentId] + ); + + const currentIdx = moments.findIndex((m) => m.version === currentVersion); + const currentMoment = currentIdx >= 0 ? moments[currentIdx] : undefined; + const previousVersion = currentIdx > 0 ? moments[currentIdx - 1].version : null; + const hasMore = moments.length > 1; + + // The list reads newest-first (most recent at the top), the reverse of the + // oldest-first chronological order moments arrive in. + const listMoments = useMemo(() => [...moments].reverse(), [moments]); + + const loadChanges = useMemo( + () => + async (): Promise => { + if (!previousVersion || !loadChangesForVersion) return []; + return loadChangesForVersion(previousVersion, currentVersion); + }, + [previousVersion, currentVersion, loadChangesForVersion] + ); + + const selectMoment = useCallback( + (version: string) => { + onNavigate(version); + setLevel('detail'); + }, + [onNavigate] + ); + + const title = level === 'moments' ? 'All versions' : 'Timeline'; + + return ( +
+
+ {level === 'moments' ? ( + + ) : ( + + )} +

{title}

+ +
+ + {level === 'detail' ? ( +
+ + {currentMoment && } +
+ ) : ( + <> + {displayName && ( +
+ {moments.length} version{moments.length === 1 ? '' : 's'} of {displayName} +
+ )} +
    + {listMoments.map((moment) => { + const selected = moment.version === currentVersion; + return ( +
  • + +
  • + ); + })} +
+ + )} +
+ ); +} 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/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 && ( -
+
+ ) : ( + + )} +

{title}

+ +
+ + + + {!searching && ( +
    + {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 88f4c7e68..4a8682971 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,69 +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)) - .filter((rev) => rev != null) - .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..09170494d --- /dev/null +++ b/calm-hub-ui/src/hub/components/tree-navigation/navigation-loaders.ts @@ -0,0 +1,123 @@ +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)) + .filter((rev) => rev != null) + .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); + } +} diff --git a/calm-hub-ui/src/index.css b/calm-hub-ui/src/index.css index 1fcdb56a3..e294c1e27 100644 --- a/calm-hub-ui/src/index.css +++ b/calm-hub-ui/src/index.css @@ -37,6 +37,21 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } +/* Full-screen detail panel slide-in (mobile push overlay) */ +@keyframes slide-in-right { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + +.animate-slide-in-right { + animation: slide-in-right 0.25s ease-out; +} + /* ReactFlow style overrides */ .react-flow__background { background-color: #ffffff; diff --git a/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx b/calm-hub-ui/src/visualizer/components/reactflow/ArchitectureGraph.tsx index 8e34914ac..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, @@ -21,6 +21,8 @@ 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 { useNodeSearch } from './node-search-context.js'; import type { ArchitectureGraphProps } from '../../contracts/contracts.js'; const edgeTypes = { custom: FloatingEdge }; @@ -37,15 +39,15 @@ 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 { onNodesChange, @@ -70,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 !== ''; @@ -128,30 +130,36 @@ export function ArchitectureGraph({ jsonData, onNodeClick, onEdgeClick, viewport style={{ background: THEME.colors.background }} > - - - - - + )} + {!isMobile && ( + + )} + {!externalSearch && ( + + + + )}
); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx b/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx index 6de3daf02..c6b5c0b06 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/PatternGraph.tsx @@ -23,6 +23,8 @@ import { parsePatternData } from './utils/patternTransformer.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 { useNodeSearch } from './node-search-context.js'; import { DecisionSelectorPanel } from './DecisionSelectorPanel.js'; import { extractDecisionPoints, @@ -55,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. @@ -65,8 +67,8 @@ export function PatternGraph({ patternData, onNodeClick, onEdgeClick, viewportKe const sourceNodesRef = useRef([]); const sourceEdgesRef = useRef([]); - const [availableNodeTypes, setAvailableNodeTypes] = useState([]); const [decisionPoints, setDecisionPoints] = useState>([]); + const isMobile = useIsMobile(); const { onNodesChange, @@ -93,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 !== ''; @@ -192,21 +194,25 @@ export function PatternGraph({ patternData, onNodeClick, onEdgeClick, viewportKe style={{ background: THEME.colors.background }} > - - + {!isMobile && ( + + )} + {!isMobile && ( + + )} + {!externalSearch && ( + )}

); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx b/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx index da8206d04..24c26a1f2 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx +++ b/calm-hub-ui/src/visualizer/components/reactflow/SearchBar.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import { Search, X } from 'lucide-react'; import { THEME } from './theme'; +import { useIsMobile } from '../../../hooks/useMediaQuery.js'; interface SearchBarProps { searchTerm: string; @@ -7,15 +9,54 @@ 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 = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: THEME.colors.card, + border: `1px solid ${THEME.colors.border}`, + borderRadius: '8px', + boxShadow: THEME.shadows.md, + width: '34px', + height: '34px', + cursor: 'pointer', + color: THEME.colors.muted, +}; + export function SearchBar({ searchTerm, onSearchChange, typeFilter, onTypeFilterChange, nodeTypes, + forceExpanded = false, }: SearchBarProps) { + const isMobile = useIsMobile(); + const [expanded, setExpanded] = useState(false); + + // 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. + // forceExpanded (e.g. inside the view menu) always shows the full bar. + const showCompact = isMobile && !expanded && !searchTerm && !typeFilter && !forceExpanded; + + if (showCompact) { + return ( + + ); + } + return (
@@ -35,13 +77,16 @@ export function SearchBar({ placeholder="Search nodes..." value={searchTerm} onChange={(e) => onSearchChange(e.target.value)} + autoFocus={isMobile && expanded} style={{ border: 'none', outline: 'none', background: 'transparent', color: THEME.colors.foreground, - fontSize: '12px', - width: '140px', + fontSize: forceExpanded ? '14px' : '12px', + minWidth: 0, + flex: forceExpanded ? 1 : undefined, + width: forceExpanded ? 'auto' : isMobile ? '110px' : '140px', }} /> {searchTerm && ( @@ -84,6 +129,28 @@ export function SearchBar({ ))} )} + {isMobile && !forceExpanded && ( + + )}
); } diff --git a/calm-hub-ui/src/visualizer/components/reactflow/node-search-context.tsx b/calm-hub-ui/src/visualizer/components/reactflow/node-search-context.tsx new file mode 100644 index 000000000..d48bb6074 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/reactflow/node-search-context.tsx @@ -0,0 +1,45 @@ +import { createContext, useContext, useState } from 'react'; + +/** + * Shared node ("component") search state for a diagram. By default each graph + * owns its own state and renders the in-canvas search bar. When a parent + * provides this context (e.g. the mobile diagram, which surfaces the search + * inside its view-options menu), the graph uses the external state instead and + * hides its in-canvas search bar. + */ +export interface NodeSearchState { + searchTerm: string; + setSearchTerm: (value: string) => void; + typeFilter: string; + setTypeFilter: (value: string) => void; + availableNodeTypes: string[]; + setAvailableNodeTypes: (value: string[]) => void; + /** True when the search UI is rendered outside the canvas (hide the in-canvas bar). */ + external: boolean; +} + +const NodeSearchContext = createContext(null); + +export const NodeSearchProvider = NodeSearchContext.Provider; + +/** + * Returns the externally-provided node-search state when a {@link NodeSearchProvider} + * is present, otherwise falls back to graph-local state. + */ +export function useNodeSearch(): NodeSearchState { + const ctx = useContext(NodeSearchContext); + const [searchTerm, setSearchTerm] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [availableNodeTypes, setAvailableNodeTypes] = useState([]); + + if (ctx) return ctx; + return { + searchTerm, + setSearchTerm, + typeFilter, + setTypeFilter, + availableNodeTypes, + setAvailableNodeTypes, + external: false, + }; +} diff --git a/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx b/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx index 12806d566..63dfa486a 100644 --- a/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx +++ b/calm-hub-ui/src/visualizer/components/sidebar/Sidebar.tsx @@ -20,8 +20,8 @@ export function Sidebar({ selectedData, closeSidebar }: SidebarProps) { const isRelationship = isCALMRelationship(selectedData); return ( -
-
+
+

{isNode ? ( diff --git a/calm-hub-ui/vitest.setup.ts b/calm-hub-ui/vitest.setup.ts index 71e3e0e8c..077b2a35f 100644 --- a/calm-hub-ui/vitest.setup.ts +++ b/calm-hub-ui/vitest.setup.ts @@ -9,6 +9,23 @@ global.ResizeObserver = class ResizeObserver { disconnect() {} }; +// Polyfill matchMedia (not implemented in jsdom). Defaults to non-matching so +// components fall back to their desktop layout in tests. Individual tests can +// override window.matchMedia to exercise mobile/responsive behaviour. +if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +} + // runs a clean after each test case (e.g. clearing jsdom) afterEach(() => { cleanup(); diff --git a/node_modules b/node_modules new file mode 120000 index 000000000..128f68253 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/matthewbain/Development/github/architecture-as-code/node_modules \ No newline at end of file