mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user