From 0a126e74a7b66018dc8914383598aad45ca7e950 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 3 May 2026 20:35:22 -0400 Subject: [PATCH] refactor(frontend): extract useViewNavigationState hook from EditorLayout (#902) * refactor(frontend): add useViewNavigationState hook with tests * refactor(frontend): wire useViewNavigationState into EditorLayout * test(frontend): add skipper tier and handleOpenSettings no-arg coverage --- frontend/src/components/EditorLayout.tsx | 113 +++------ .../__tests__/useViewNavigationState.test.tsx | 230 ++++++++++++++++++ .../hooks/useViewNavigationState.ts | 115 +++++++++ 3 files changed, 373 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx create mode 100644 frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 0e5b5ff5..376eb3b9 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,12 +1,11 @@ -import { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspense } from 'react'; +import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'; type Theme = 'light' | 'dark' | 'auto'; import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import LazyBoundary from './LazyBoundary'; import { Button } from './ui/button'; -import { Plus, Terminal, CloudDownload, Home, HardDrive, ScrollText, Activity, Radar, RefreshCw, Clock } from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; +import { Plus } from 'lucide-react'; import { type Label as StackLabel, type LabelColor } from './label-types'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; @@ -14,7 +13,6 @@ import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog'; import { TopBar } from './TopBar'; -import type { SectionId } from './settings/types'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog'; @@ -22,11 +20,11 @@ import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog'; import { EditorView, type StackAction } from './EditorLayout/EditorView'; import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState'; import { useStackListState } from './EditorLayout/hooks/useStackListState'; +import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; import { LogViewer } from './LogViewer'; -import type { ScheduleTaskPrefill } from './ScheduledOperationsView'; // SecurityHistoryView is the only lazy-loaded view that lives outside // the ViewRouter switch — it renders as an overlay sheet wired into the @@ -35,8 +33,6 @@ import type { ScheduleTaskPrefill } from './ScheduledOperationsView'; const SecurityHistoryView = lazy(() => import('./SecurityHistoryView').then(m => ({ default: m.SecurityHistoryView })), ); -import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; -import type { SenchoNavigateDetail } from './NodeManager'; import { NodeSwitcher } from './NodeSwitcher'; import { GlobalCommandPalette, @@ -190,12 +186,6 @@ export default function EditorLayout() { ); const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); - const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'settings'>('dashboard'); - const [settingsSection, setSettingsSection] = useState('appearance'); - const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false); - const [filterNodeId, setFilterNodeId] = useState(null); - const [schedulePrefill, setSchedulePrefill] = useState(null); - const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []); const [diffPreview, setDiffPreview] = useState<{ mode: 'save' | 'save-and-deploy'; language: 'yaml' | 'ini'; @@ -213,52 +203,6 @@ export default function EditorLayout() { const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null); - const isAdmiral = license?.variant === 'admiral'; - - const handleOpenSettings = useCallback((section?: SectionId) => { - if (section) setSettingsSection(section); - setActiveView('settings'); - setFilterNodeId(null); - }, []); - - // Notifications state - const [notifications, setNotifications] = useState([]); - const [tickerConnected, setTickerConnected] = useState(false); - const [alertSheetOpen, setAlertSheetOpen] = useState(false); - const [alertSheetStack, setAlertSheetStack] = useState(''); - const [autoHealStackName, setAutoHealStackName] = useState(null); - - // Mobile navigation sheet state - const [mobileNavOpen, setMobileNavOpen] = useState(false); - - const openAlertSheet = (stackName: string) => { - setAlertSheetStack(stackName); - setAlertSheetOpen(true); - }; - - // Navigation items (permission-aware, data-driven) - const navItems = useMemo(() => { - const items: Array<{ value: string; label: string; icon: LucideIcon }> = [ - { value: 'dashboard', label: 'Home', icon: Home }, - { value: 'fleet', label: 'Fleet', icon: Radar }, - ]; - items.push( - { value: 'resources', label: 'Resources', icon: HardDrive }, - { value: 'templates', label: 'App Store', icon: CloudDownload }, - { value: 'global-observability', label: 'Logs', icon: Activity }, - ); - if (isPaid && isAdmin) { - items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw }); - } - if (isPaid && license?.variant === 'admiral') { - if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal }); - if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); - if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); - } - return items; - }, [isAdmin, isPaid, license?.variant, can]); - - // Reset editor state (extracted from Home button onClick) const resetEditorState = () => { setSelectedFile(null); setContent(''); @@ -272,15 +216,31 @@ export default function EditorLayout() { setIsEditing(false); }; - const handleNavigate = (value: string) => { - if (value === activeView) return; - if (value === 'dashboard') { - resetEditorState(); - setActiveView('dashboard'); - } else { - setActiveView(value as typeof activeView); - setFilterNodeId(null); - } + const { + activeView, setActiveView, + settingsSection, setSettingsSection, + securityHistoryOpen, setSecurityHistoryOpen, + filterNodeId, setFilterNodeId, + schedulePrefill, setSchedulePrefill, + mobileNavOpen, setMobileNavOpen, + handleOpenSettings, + handlePrefillConsumed, + handleNavigate, + navItems, + } = useViewNavigationState({ onNavigateToDashboard: resetEditorState }); + + const isAdmiral = license?.variant === 'admiral'; + + // Notifications state + const [notifications, setNotifications] = useState([]); + const [tickerConnected, setTickerConnected] = useState(false); + const [alertSheetOpen, setAlertSheetOpen] = useState(false); + const [alertSheetStack, setAlertSheetStack] = useState(''); + const [autoHealStackName, setAutoHealStackName] = useState(null); + + const openAlertSheet = (stackName: string) => { + setAlertSheetStack(stackName); + setAlertSheetOpen(true); }; // Listen for system dark mode changes (for 'auto' theme) @@ -297,23 +257,6 @@ export default function EditorLayout() { localStorage.setItem('sencho-theme', theme); }, [isDarkMode, theme]); - // Listen for cross-component navigation (e.g., NodeManager → Schedules) - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (!detail?.view) return; - if (detail.view === 'security-history') { - setSecurityHistoryOpen(true); - setFilterNodeId(detail.nodeId ?? null); - return; - } - setActiveView(detail.view); - setFilterNodeId(detail.nodeId ?? null); - }; - window.addEventListener(SENCHO_NAVIGATE_EVENT, handler); - return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler); - }, []); - // Force Monaco to re-measure its container after the tab switch DOM settles. // Monaco's internal child is position:static with an explicit pixel height that // creates a circular CSS dependency (Monaco drives card height → grid height → Monaco). diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx new file mode 100644 index 00000000..f207c562 --- /dev/null +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import * as AuthContext from '@/context/AuthContext'; +import * as LicenseContext from '@/context/LicenseContext'; +import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager'; +import { useViewNavigationState } from '../hooks/useViewNavigationState'; + +vi.mock('@/context/AuthContext'); +vi.mock('@/context/LicenseContext'); + +function mockCommunityUser() { + vi.mocked(AuthContext.useAuth).mockReturnValue({ + isAdmin: false, + can: () => false, + } as unknown as ReturnType); + vi.mocked(LicenseContext.useLicense).mockReturnValue({ + isPaid: false, + license: null, + } as unknown as ReturnType); +} + +function mockAdmiralAdmin() { + vi.mocked(AuthContext.useAuth).mockReturnValue({ + isAdmin: true, + can: (p: string) => p === 'system:audit', + } as unknown as ReturnType); + vi.mocked(LicenseContext.useLicense).mockReturnValue({ + isPaid: true, + license: { variant: 'admiral' } as ReturnType['license'], + } as unknown as ReturnType); +} + +function mockSkipperAdmin() { + vi.mocked(AuthContext.useAuth).mockReturnValue({ + isAdmin: true, + can: () => false, + } as unknown as ReturnType); + vi.mocked(LicenseContext.useLicense).mockReturnValue({ + isPaid: true, + license: { variant: 'skipper' } as ReturnType['license'], + } as unknown as ReturnType); +} + +describe('useViewNavigationState', () => { + beforeEach(() => { + mockCommunityUser(); + }); + + // ── initial state ────────────────────────────────────────────────────────── + + it('returns default state on mount', () => { + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.activeView).toBe('dashboard'); + expect(result.current.settingsSection).toBe('appearance'); + expect(result.current.securityHistoryOpen).toBe(false); + expect(result.current.filterNodeId).toBeNull(); + expect(result.current.schedulePrefill).toBeNull(); + expect(result.current.mobileNavOpen).toBe(false); + }); + + // ── handleNavigate ───────────────────────────────────────────────────────── + + it('handleNavigate is a no-op when navigating to the current view', () => { + const onNavigateToDashboard = vi.fn(); + const { result } = renderHook(() => + useViewNavigationState({ onNavigateToDashboard }), + ); + act(() => result.current.handleNavigate('dashboard')); + expect(onNavigateToDashboard).not.toHaveBeenCalled(); + expect(result.current.activeView).toBe('dashboard'); + }); + + it('handleNavigate to dashboard calls onNavigateToDashboard and sets activeView', () => { + const onNavigateToDashboard = vi.fn(); + const { result } = renderHook(() => + useViewNavigationState({ onNavigateToDashboard }), + ); + // Navigate away first so dashboard→dashboard no-op guard does not fire + act(() => result.current.handleNavigate('fleet')); + expect(result.current.activeView).toBe('fleet'); + + act(() => result.current.handleNavigate('dashboard')); + expect(onNavigateToDashboard).toHaveBeenCalledOnce(); + expect(result.current.activeView).toBe('dashboard'); + }); + + it('handleNavigate to a non-dashboard view sets activeView and clears filterNodeId', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', nodeId: 42 } }), + ); + }); + expect(result.current.filterNodeId).toBe(42); + + act(() => result.current.handleNavigate('resources')); + expect(result.current.activeView).toBe('resources'); + expect(result.current.filterNodeId).toBeNull(); + }); + + // ── handleOpenSettings ───────────────────────────────────────────────────── + + it('handleOpenSettings navigates to settings and clears filterNodeId', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', nodeId: 7 } }), + ); + }); + act(() => result.current.handleOpenSettings()); + expect(result.current.activeView).toBe('settings'); + expect(result.current.filterNodeId).toBeNull(); + }); + + it('handleOpenSettings with a section updates settingsSection', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => result.current.handleOpenSettings('nodes')); + expect(result.current.settingsSection).toBe('nodes'); + expect(result.current.activeView).toBe('settings'); + }); + + it('handleOpenSettings without a section does not change settingsSection', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => result.current.handleOpenSettings('labels')); + act(() => result.current.handleOpenSettings()); + expect(result.current.settingsSection).toBe('labels'); + expect(result.current.activeView).toBe('settings'); + }); + + // ── handlePrefillConsumed ────────────────────────────────────────────────── + + it('handlePrefillConsumed clears schedulePrefill', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => result.current.setSchedulePrefill({ stackName: 'web.yml', nodeId: 1 })); + expect(result.current.schedulePrefill).toEqual({ stackName: 'web.yml', nodeId: 1 }); + act(() => result.current.handlePrefillConsumed()); + expect(result.current.schedulePrefill).toBeNull(); + }); + + // ── SENCHO_NAVIGATE_EVENT ────────────────────────────────────────────────── + + it('SENCHO_NAVIGATE_EVENT sets activeView and filterNodeId', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', nodeId: 5 } }), + ); + }); + expect(result.current.activeView).toBe('fleet'); + expect(result.current.filterNodeId).toBe(5); + }); + + it('SENCHO_NAVIGATE_EVENT with security-history opens the sheet without changing activeView', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security-history', nodeId: 3 } }), + ); + }); + expect(result.current.securityHistoryOpen).toBe(true); + expect(result.current.filterNodeId).toBe(3); + expect(result.current.activeView).toBe('dashboard'); + }); + + it('SENCHO_NAVIGATE_EVENT with no nodeId sets filterNodeId to null', () => { + const { result } = renderHook(() => useViewNavigationState()); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', nodeId: 9 } }), + ); + }); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'resources' } }), + ); + }); + expect(result.current.filterNodeId).toBeNull(); + }); + + it('cleans up SENCHO_NAVIGATE_EVENT listener on unmount', () => { + const { result, unmount } = renderHook(() => useViewNavigationState()); + unmount(); + act(() => { + window.dispatchEvent( + new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet' } }), + ); + }); + expect(result.current.activeView).toBe('dashboard'); + }); + + // ── navItems: community user ─────────────────────────────────────────────── + + it('navItems for community non-paid user contains base items only', () => { + const { result } = renderHook(() => useViewNavigationState()); + const values = result.current.navItems.map(i => i.value); + expect(values).toContain('dashboard'); + expect(values).toContain('fleet'); + expect(values).toContain('resources'); + expect(values).toContain('templates'); + expect(values).toContain('global-observability'); + expect(values).not.toContain('auto-updates'); + expect(values).not.toContain('host-console'); + expect(values).not.toContain('audit-log'); + expect(values).not.toContain('scheduled-ops'); + }); + + // ── navItems: admiral admin ──────────────────────────────────────────────── + + it('navItems for admiral paid admin contains all items', () => { + mockAdmiralAdmin(); + const { result } = renderHook(() => useViewNavigationState()); + const values = result.current.navItems.map(i => i.value); + expect(values).toContain('auto-updates'); + expect(values).toContain('host-console'); + expect(values).toContain('audit-log'); + expect(values).toContain('scheduled-ops'); + }); + + // ── navItems: skipper admin ──────────────────────────────────────────────── + + it('navItems for skipper paid admin contains auto-updates but not admiral items', () => { + mockSkipperAdmin(); + const { result } = renderHook(() => useViewNavigationState()); + const values = result.current.navItems.map(i => i.value); + expect(values).toContain('auto-updates'); + expect(values).not.toContain('host-console'); + expect(values).not.toContain('audit-log'); + expect(values).not.toContain('scheduled-ops'); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts new file mode 100644 index 00000000..4f72e830 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -0,0 +1,115 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + Terminal, CloudDownload, Home, HardDrive, ScrollText, + Activity, Radar, RefreshCw, Clock, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; +import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager'; +import type { SenchoNavigateDetail } from '@/components/NodeManager'; +import type { SectionId } from '@/components/settings/types'; +import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView'; + +export type ActiveView = + | 'dashboard' + | 'editor' + | 'host-console' + | 'resources' + | 'templates' + | 'global-observability' + | 'fleet' + | 'audit-log' + | 'scheduled-ops' + | 'auto-updates' + | 'settings'; + +export interface NavItem { + value: string; + label: string; + icon: LucideIcon; +} + +interface UseViewNavigationStateOptions { + onNavigateToDashboard?: () => void; +} + +export function useViewNavigationState(options?: UseViewNavigationStateOptions) { + const { onNavigateToDashboard } = options ?? {}; + const { isAdmin, can } = useAuth(); + const { isPaid, license } = useLicense(); + + const [activeView, setActiveView] = useState('dashboard'); + const [settingsSection, setSettingsSection] = useState('appearance'); + const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false); + const [filterNodeId, setFilterNodeId] = useState(null); + const [schedulePrefill, setSchedulePrefill] = useState(null); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + + const handleOpenSettings = useCallback((section?: SectionId) => { + if (section) setSettingsSection(section); + setActiveView('settings'); + setFilterNodeId(null); + }, []); + + const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []); + + const handleNavigate = useCallback((value: string) => { + if (value === activeView) return; + if (value === 'dashboard') { + onNavigateToDashboard?.(); + setActiveView('dashboard'); + } else { + setActiveView(value as ActiveView); + setFilterNodeId(null); + } + }, [activeView, onNavigateToDashboard]); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.view) return; + if (detail.view === 'security-history') { + setSecurityHistoryOpen(true); + setFilterNodeId(detail.nodeId ?? null); + return; + } + setActiveView(detail.view as ActiveView); + setFilterNodeId(detail.nodeId ?? null); + }; + window.addEventListener(SENCHO_NAVIGATE_EVENT, handler); + return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler); + }, []); + + const navItems = useMemo((): NavItem[] => { + const items: NavItem[] = [ + { value: 'dashboard', label: 'Home', icon: Home }, + { value: 'fleet', label: 'Fleet', icon: Radar }, + { value: 'resources', label: 'Resources', icon: HardDrive }, + { value: 'templates', label: 'App Store', icon: CloudDownload }, + { value: 'global-observability', label: 'Logs', icon: Activity }, + ]; + if (isPaid && isAdmin) { + items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw }); + } + if (isPaid && license?.variant === 'admiral') { + if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal }); + if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); + if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); + } + return items; + }, [isAdmin, isPaid, license?.variant, can]); + + return { + activeView, setActiveView, + settingsSection, setSettingsSection, + securityHistoryOpen, setSecurityHistoryOpen, + filterNodeId, setFilterNodeId, + schedulePrefill, setSchedulePrefill, + mobileNavOpen, setMobileNavOpen, + handleOpenSettings, + handlePrefillConsumed, + handleNavigate, + navItems, + } as const; +}