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
This commit is contained in:
Anso
2026-05-03 20:35:22 -04:00
committed by GitHub
parent d5393a6027
commit 0a126e74a7
3 changed files with 373 additions and 85 deletions
+28 -85
View File
@@ -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<SectionId>('appearance');
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(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<NotificationItem[]>([]);
const [tickerConnected, setTickerConnected] = useState(false);
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
const [alertSheetStack, setAlertSheetStack] = useState('');
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(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<NotificationItem[]>([]);
const [tickerConnected, setTickerConnected] = useState(false);
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
const [alertSheetStack, setAlertSheetStack] = useState('');
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(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<SenchoNavigateDetail>).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).
@@ -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<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
license: null,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
function mockAdmiralAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'system:audit',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: true,
license: { variant: 'admiral' } as ReturnType<typeof LicenseContext.useLicense>['license'],
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
function mockSkipperAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: () => false,
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: true,
license: { variant: 'skipper' } as ReturnType<typeof LicenseContext.useLicense>['license'],
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
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');
});
});
@@ -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<ActiveView>('dashboard');
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(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<SenchoNavigateDetail & { view: string }>).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;
}