diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index db8b9f9..e00cff4 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,33 +1,29 @@ 'use client'; -import { useRouter } from 'next/navigation'; -import { Box, Container, Typography, Grid, CircularProgress, useMediaQuery, useTheme } from '@mui/material'; -import { useAppDispatch } from '@/lib/store/hooks'; -import { logout as logoutAction } from '@/lib/store/authSlice'; -import { useAuthRedirect } from '@/lib/hooks/useAuthRedirect'; -import { useContainerList } from '@/lib/hooks/useContainerList'; -import { useTerminalModal } from '@/lib/hooks/useTerminalModal'; +import { Box, Container, Typography, Grid, CircularProgress } from '@mui/material'; +import { useDashboard } from '@/lib/hooks/useDashboard'; import DashboardHeader from '@/components/Dashboard/DashboardHeader'; import EmptyState from '@/components/Dashboard/EmptyState'; import ContainerCard from '@/components/ContainerCard'; import TerminalModal from '@/components/TerminalModal'; export default function Dashboard() { - const { isAuthenticated, loading: authLoading } = useAuthRedirect('/'); - const dispatch = useAppDispatch(); - const router = useRouter(); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const { + containers, + isRefreshing, + error, + refreshContainers, + selectedContainer, + isTerminalOpen, + openTerminal, + closeTerminal, + isMobile, + isInitialLoading, + showEmptyState, + handleLogout, + } = useDashboard(); - const { containers, isRefreshing, isLoading, error, refreshContainers } = useContainerList(isAuthenticated); - const { selectedContainer, isTerminalOpen, openTerminal, closeTerminal } = useTerminalModal(); - - const handleLogout = async () => { - await dispatch(logoutAction()); - router.push('/'); - }; - - if (authLoading || isLoading) { + if (isInitialLoading) { return ( )} - {containers.length === 0 && !isLoading ? ( + {showEmptyState ? ( ) : ( diff --git a/frontend/components/TerminalModal.tsx b/frontend/components/TerminalModal.tsx index 5199bab..70f22a0 100644 --- a/frontend/components/TerminalModal.tsx +++ b/frontend/components/TerminalModal.tsx @@ -1,9 +1,10 @@ 'use client'; -import React, { useState } from 'react'; -import { Dialog, DialogContent, DialogActions, Button, useMediaQuery, useTheme } from '@mui/material'; +import React from 'react'; +import { Dialog, DialogContent, DialogActions, Button } from '@mui/material'; import { useSimpleTerminal } from '@/lib/hooks/useSimpleTerminal'; import { useInteractiveTerminal } from '@/lib/hooks/useInteractiveTerminal'; +import { useTerminalModalState } from '@/lib/hooks/useTerminalModalState'; import { TerminalModalProps } from '@/lib/interfaces/terminal'; import TerminalHeader from './TerminalModal/TerminalHeader'; import SimpleTerminal from './TerminalModal/SimpleTerminal'; @@ -16,59 +17,24 @@ export default function TerminalModal({ containerName, containerId, }: TerminalModalProps) { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - - const [mode, setMode] = useState<'simple' | 'interactive'>('interactive'); - const [interactiveFailed, setInteractiveFailed] = useState(false); - const [fallbackReason, setFallbackReason] = useState(''); - const [showFallbackNotification, setShowFallbackNotification] = useState(false); - + const modalState = useTerminalModalState(); const simpleTerminal = useSimpleTerminal(containerId); - const handleFallback = (reason: string) => { - console.warn('Falling back to simple mode:', reason); - setInteractiveFailed(true); - setFallbackReason(reason); - setMode('simple'); - setShowFallbackNotification(true); - interactiveTerminal.cleanup(); - }; - const interactiveTerminal = useInteractiveTerminal({ - open: open && mode === 'interactive', + open: open && modalState.mode === 'interactive', containerId, containerName, - isMobile, - onFallback: handleFallback, + isMobile: modalState.isMobile, + onFallback: modalState.handleFallback, }); const handleClose = () => { interactiveTerminal.cleanup(); simpleTerminal.reset(); + modalState.reset(); onClose(); }; - const handleModeChange = ( - event: React.MouseEvent, - newMode: 'simple' | 'interactive' | null, - ) => { - if (newMode !== null) { - if (newMode === 'interactive' && interactiveFailed) { - setInteractiveFailed(false); - setFallbackReason(''); - } - setMode(newMode); - } - }; - - const handleRetryInteractive = () => { - setInteractiveFailed(false); - setFallbackReason(''); - setShowFallbackNotification(false); - setMode('interactive'); - }; - const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -82,24 +48,24 @@ export default function TerminalModal({ onClose={handleClose} maxWidth="md" fullWidth - fullScreen={isMobile} + fullScreen={modalState.isMobile} PaperProps={{ sx: { - minHeight: isMobile ? '100vh' : '500px', - maxHeight: isMobile ? '100vh' : '80vh', + minHeight: modalState.isMobile ? '100vh' : '500px', + maxHeight: modalState.isMobile ? '100vh' : '80vh', }, }} > - {mode === 'interactive' ? ( + {modalState.mode === 'interactive' ? ( ) : ( setShowFallbackNotification(false)} - onRetry={handleRetryInteractive} + show={modalState.showFallbackNotification} + reason={modalState.fallbackReason} + onClose={() => modalState.reset()} + onRetry={modalState.handleRetryInteractive} /> ); diff --git a/frontend/lib/hooks/__tests__/useContainerActions.test.tsx b/frontend/lib/hooks/__tests__/useContainerActions.test.tsx new file mode 100644 index 0000000..acc9c0f --- /dev/null +++ b/frontend/lib/hooks/__tests__/useContainerActions.test.tsx @@ -0,0 +1,194 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useContainerActions } from '../useContainerActions'; +import { apiClient } from '@/lib/api'; + +jest.mock('@/lib/api'); + +const mockApiClient = apiClient as jest.Mocked; + +describe('useContainerActions', () => { + const containerId = 'container123'; + const mockOnUpdate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('handleStart', () => { + it('should start container and show success', async () => { + mockApiClient.startContainer.mockResolvedValueOnce({ message: 'Started' }); + + const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate)); + + await act(async () => { + await result.current.handleStart(); + }); + + expect(mockApiClient.startContainer).toHaveBeenCalledWith(containerId); + expect(mockOnUpdate).toHaveBeenCalled(); + expect(result.current.snackbar.open).toBe(true); + expect(result.current.snackbar.message).toBe('Container started successfully'); + expect(result.current.snackbar.severity).toBe('success'); + expect(result.current.isLoading).toBe(false); + }); + + it('should handle start error', async () => { + mockApiClient.startContainer.mockRejectedValueOnce(new Error('Start failed')); + + const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate)); + + await act(async () => { + await result.current.handleStart(); + }); + + expect(mockOnUpdate).not.toHaveBeenCalled(); + expect(result.current.snackbar.severity).toBe('error'); + expect(result.current.snackbar.message).toContain('Failed to start'); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('handleStop', () => { + it('should stop container and show success', async () => { + mockApiClient.stopContainer.mockResolvedValueOnce({ message: 'Stopped' }); + + const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate)); + + await act(async () => { + await result.current.handleStop(); + }); + + expect(mockApiClient.stopContainer).toHaveBeenCalledWith(containerId); + expect(mockOnUpdate).toHaveBeenCalled(); + expect(result.current.snackbar.message).toBe('Container stopped successfully'); + }); + + it('should handle stop error', async () => { + mockApiClient.stopContainer.mockRejectedValueOnce(new Error('Stop failed')); + + const { result } = renderHook(() => useContainerActions(containerId)); + + await act(async () => { + await result.current.handleStop(); + }); + + expect(result.current.snackbar.severity).toBe('error'); + }); + }); + + describe('handleRestart', () => { + it('should restart container and show success', async () => { + mockApiClient.restartContainer.mockResolvedValueOnce({ message: 'Restarted' }); + + const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate)); + + await act(async () => { + await result.current.handleRestart(); + }); + + expect(mockApiClient.restartContainer).toHaveBeenCalledWith(containerId); + expect(result.current.snackbar.message).toBe('Container restarted successfully'); + }); + + it('should handle restart error', async () => { + mockApiClient.restartContainer.mockRejectedValueOnce(new Error('Restart failed')); + + const { result } = renderHook(() => useContainerActions(containerId)); + + await act(async () => { + await result.current.handleRestart(); + }); + + expect(result.current.snackbar.severity).toBe('error'); + }); + }); + + describe('handleRemove', () => { + it('should remove container and show success', async () => { + mockApiClient.removeContainer.mockResolvedValueOnce({ message: 'Removed' }); + + const { result } = renderHook(() => useContainerActions(containerId, mockOnUpdate)); + + await act(async () => { + await result.current.handleRemove(); + }); + + expect(mockApiClient.removeContainer).toHaveBeenCalledWith(containerId); + expect(result.current.snackbar.message).toBe('Container removed successfully'); + }); + + it('should handle remove error', async () => { + mockApiClient.removeContainer.mockRejectedValueOnce(new Error('Remove failed')); + + const { result } = renderHook(() => useContainerActions(containerId)); + + await act(async () => { + await result.current.handleRemove(); + }); + + expect(result.current.snackbar.severity).toBe('error'); + expect(result.current.snackbar.message).toContain('Failed to remove'); + }); + }); + + describe('closeSnackbar', () => { + it('should close snackbar', async () => { + mockApiClient.startContainer.mockResolvedValueOnce({ message: 'Started' }); + + const { result } = renderHook(() => useContainerActions(containerId)); + + await act(async () => { + await result.current.handleStart(); + }); + + expect(result.current.snackbar.open).toBe(true); + + act(() => { + result.current.closeSnackbar(); + }); + + expect(result.current.snackbar.open).toBe(false); + }); + }); + + describe('loading state', () => { + it('should set loading during operation', async () => { + let resolveStart: (value: any) => void; + const startPromise = new Promise((resolve) => { + resolveStart = resolve; + }); + + mockApiClient.startContainer.mockReturnValue(startPromise as any); + + const { result } = renderHook(() => useContainerActions(containerId)); + + act(() => { + result.current.handleStart(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + await act(async () => { + resolveStart!({ message: 'Started' }); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + }); + + it('should handle non-Error objects in catch block', async () => { + mockApiClient.startContainer.mockRejectedValueOnce('String error'); + + const { result } = renderHook(() => useContainerActions(containerId)); + + await act(async () => { + await result.current.handleStart(); + }); + + expect(result.current.snackbar.message).toContain('Unknown error'); + }); +}); diff --git a/frontend/lib/hooks/__tests__/useContainerList.test.tsx b/frontend/lib/hooks/__tests__/useContainerList.test.tsx new file mode 100644 index 0000000..a2a4a87 --- /dev/null +++ b/frontend/lib/hooks/__tests__/useContainerList.test.tsx @@ -0,0 +1,183 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useContainerList } from '../useContainerList'; +import { apiClient } from '@/lib/api'; + +jest.mock('@/lib/api'); + +const mockApiClient = apiClient as jest.Mocked; + +describe('useContainerList', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should not fetch when not authenticated', () => { + renderHook(() => useContainerList(false)); + + expect(mockApiClient.getContainers).not.toHaveBeenCalled(); + }); + + it('should fetch containers when authenticated', async () => { + const mockContainers = [ + { id: '1', name: 'container1', image: 'nginx', status: 'running', uptime: '1h' }, + { id: '2', name: 'container2', image: 'redis', status: 'stopped', uptime: '0m' }, + ]; + + mockApiClient.getContainers.mockResolvedValueOnce(mockContainers); + + const { result } = renderHook(() => useContainerList(true)); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.containers).toEqual(mockContainers); + }); + + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(''); + }); + + it('should handle fetch error', async () => { + mockApiClient.getContainers.mockRejectedValueOnce(new Error('Fetch failed')); + + const { result } = renderHook(() => useContainerList(true)); + + await waitFor(() => { + expect(result.current.error).toBe('Fetch failed'); + }); + + expect(result.current.containers).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + + it('should handle non-Error objects', async () => { + mockApiClient.getContainers.mockRejectedValueOnce('String error'); + + const { result } = renderHook(() => useContainerList(true)); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to fetch containers'); + }); + }); + + it('should refresh automatically every 10 seconds', async () => { + const mockContainers = [{ id: '1', name: 'test', image: 'nginx', status: 'running', uptime: '1h' }]; + mockApiClient.getContainers.mockResolvedValue(mockContainers); + + renderHook(() => useContainerList(true)); + + await waitFor(() => { + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(1); + }); + + // Advance 10 seconds + act(() => { + jest.advanceTimersByTime(10000); + }); + + await waitFor(() => { + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(2); + }); + + // Advance another 10 seconds + act(() => { + jest.advanceTimersByTime(10000); + }); + + await waitFor(() => { + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(3); + }); + }); + + it('should manually refresh containers', async () => { + const mockContainers = [{ id: '1', name: 'test', image: 'nginx', status: 'running', uptime: '1h' }]; + mockApiClient.getContainers.mockResolvedValue(mockContainers); + + const { result } = renderHook(() => useContainerList(true)); + + await waitFor(() => { + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(result.current.isRefreshing).toBe(false); + }); + + await act(async () => { + await result.current.refreshContainers(); + }); + + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(2); + }); + + it('should set isRefreshing during manual refresh', async () => { + let resolveGet: (value: any) => void; + const getPromise = new Promise((resolve) => { + resolveGet = resolve; + }); + + mockApiClient.getContainers.mockReturnValue(getPromise as any); + + const { result } = renderHook(() => useContainerList(true)); + + act(() => { + result.current.refreshContainers(); + }); + + await waitFor(() => { + expect(result.current.isRefreshing).toBe(true); + }); + + await act(async () => { + resolveGet!([]); + }); + + await waitFor(() => { + expect(result.current.isRefreshing).toBe(false); + }); + }); + + it('should cleanup interval on unmount', async () => { + const mockContainers = [{ id: '1', name: 'test', image: 'nginx', status: 'running', uptime: '1h' }]; + mockApiClient.getContainers.mockResolvedValue(mockContainers); + + const { unmount } = renderHook(() => useContainerList(true)); + + await waitFor(() => { + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(1); + }); + + unmount(); + + // Advance timers - should not fetch again after unmount + act(() => { + jest.advanceTimersByTime(20000); + }); + + // Should still be 1 call (the initial one) + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(1); + }); + + it('should re-fetch when authentication changes', async () => { + const mockContainers = [{ id: '1', name: 'test', image: 'nginx', status: 'running', uptime: '1h' }]; + mockApiClient.getContainers.mockResolvedValue(mockContainers); + + const { rerender } = renderHook(({ isAuth }) => useContainerList(isAuth), { + initialProps: { isAuth: false }, + }); + + expect(mockApiClient.getContainers).not.toHaveBeenCalled(); + + rerender({ isAuth: true }); + + await waitFor(() => { + expect(mockApiClient.getContainers).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/lib/hooks/__tests__/useSimpleTerminal.test.tsx b/frontend/lib/hooks/__tests__/useSimpleTerminal.test.tsx new file mode 100644 index 0000000..95e9c4c --- /dev/null +++ b/frontend/lib/hooks/__tests__/useSimpleTerminal.test.tsx @@ -0,0 +1,279 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useSimpleTerminal } from '../useSimpleTerminal'; +import { apiClient } from '@/lib/api'; + +jest.mock('@/lib/api'); + +const mockApiClient = apiClient as jest.Mocked; + +// Mock apiClient.executeCommand - note the different method name +(mockApiClient as any).executeCommand = jest.fn(); + +describe('useSimpleTerminal', () => { + const containerId = 'container123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize with empty state', () => { + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + expect(result.current.command).toBe(''); + expect(result.current.output).toEqual([]); + expect(result.current.isExecuting).toBe(false); + expect(result.current.workdir).toBe('/'); + }); + + it('should update command', () => { + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('ls -la'); + }); + + expect(result.current.command).toBe('ls -la'); + }); + + it('should execute command successfully', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: 'file1.txt\nfile2.txt', + exit_code: 0, + workdir: '/', + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('ls'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect((mockApiClient as any).executeCommand).toHaveBeenCalledWith(containerId, 'ls'); + expect(result.current.output).toHaveLength(2); + expect(result.current.output[0].type).toBe('command'); + expect(result.current.output[0].content).toBe('ls'); + expect(result.current.output[1].type).toBe('output'); + expect(result.current.output[1].content).toBe('file1.txt\nfile2.txt'); + expect(result.current.command).toBe(''); + }); + + it('should not execute empty command', async () => { + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand(''); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect((mockApiClient as any).executeCommand).not.toHaveBeenCalled(); + }); + + it('should not execute whitespace-only command', async () => { + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand(' '); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect((mockApiClient as any).executeCommand).not.toHaveBeenCalled(); + }); + + it('should handle command error', async () => { + (mockApiClient as any).executeCommand.mockRejectedValueOnce(new Error('Command failed')); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('invalid'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect(result.current.output).toHaveLength(2); + expect(result.current.output[0].type).toBe('command'); + expect(result.current.output[1].type).toBe('error'); + expect(result.current.output[1].content).toContain('Command failed'); + }); + + it('should handle non-Error objects', async () => { + (mockApiClient as any).executeCommand.mockRejectedValueOnce('String error'); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('test'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect(result.current.output[1].content).toContain('Unknown error'); + }); + + it('should update workdir from command result', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: '', + exit_code: 0, + workdir: '/tmp', + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('cd /tmp'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect(result.current.workdir).toBe('/tmp'); + }); + + it('should show error type for non-zero exit code', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: 'command not found', + exit_code: 127, + workdir: '/', + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('invalid_cmd'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect(result.current.output[1].type).toBe('error'); + expect(result.current.output[1].content).toBe('command not found'); + }); + + it('should show empty directory message for ls with no output', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: '', + exit_code: 0, + workdir: '/', + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('ls'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + expect(result.current.output[1].type).toBe('output'); + expect(result.current.output[1].content).toBe('(empty directory)'); + }); + + it('should not show empty directory message for non-ls commands', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: '', + exit_code: 0, + workdir: '/', + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('pwd'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + // Should only have command output, no additional empty directory message + expect(result.current.output).toHaveLength(1); + }); + + it('should reset terminal', () => { + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('test command'); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.command).toBe(''); + expect(result.current.output).toEqual([]); + expect(result.current.workdir).toBe('/'); + }); + + it('should set isExecuting during command execution', async () => { + let resolveExecute: (value: any) => void; + const executePromise = new Promise((resolve) => { + resolveExecute = resolve; + }); + + (mockApiClient as any).executeCommand.mockReturnValue(executePromise); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('ls'); + }); + + act(() => { + result.current.executeCommand(); + }); + + await waitFor(() => { + expect(result.current.isExecuting).toBe(true); + }); + + await act(async () => { + resolveExecute!({ output: '', exit_code: 0, workdir: '/' }); + }); + + await waitFor(() => { + expect(result.current.isExecuting).toBe(false); + }); + }); + + it('should include workdir in command output', async () => { + (mockApiClient as any).executeCommand.mockResolvedValueOnce({ + output: 'test', + exit_code: 0, + workdir: '/home/user', + }); + + const { result } = renderHook(() => useSimpleTerminal(containerId)); + + act(() => { + result.current.setCommand('pwd'); + }); + + await act(async () => { + await result.current.executeCommand(); + }); + + // The command OutputLine has the workdir from BEFORE command execution ('/') + expect(result.current.output[0].workdir).toBe('/'); + // The hook state is updated to the NEW workdir from the result + expect(result.current.workdir).toBe('/home/user'); + }); +}); diff --git a/frontend/lib/hooks/__tests__/useTerminalModalState.test.tsx b/frontend/lib/hooks/__tests__/useTerminalModalState.test.tsx new file mode 100644 index 0000000..ff411cb --- /dev/null +++ b/frontend/lib/hooks/__tests__/useTerminalModalState.test.tsx @@ -0,0 +1,128 @@ +import { renderHook, act } from '@testing-library/react'; +import { useTerminalModalState } from '../useTerminalModalState'; + +// Mock MUI hooks +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + useTheme: () => ({ + breakpoints: { + down: () => {}, + }, + }), + useMediaQuery: () => false, +})); + +describe('useTerminalModalState', () => { + it('should initialize with interactive mode', () => { + const { result } = renderHook(() => useTerminalModalState()); + + expect(result.current.mode).toBe('interactive'); + expect(result.current.interactiveFailed).toBe(false); + expect(result.current.fallbackReason).toBe(''); + expect(result.current.showFallbackNotification).toBe(false); + }); + + it('should handle fallback to simple mode', () => { + const { result } = renderHook(() => useTerminalModalState()); + + act(() => { + result.current.handleFallback('Connection failed'); + }); + + expect(result.current.mode).toBe('simple'); + expect(result.current.interactiveFailed).toBe(true); + expect(result.current.fallbackReason).toBe('Connection failed'); + }); + + it('should handle mode change', () => { + const { result } = renderHook(() => useTerminalModalState()); + + const mockEvent = {} as React.MouseEvent; + + act(() => { + result.current.handleModeChange(mockEvent, 'simple'); + }); + + expect(result.current.mode).toBe('simple'); + }); + + it('should ignore null mode change', () => { + const { result } = renderHook(() => useTerminalModalState()); + + const mockEvent = {} as React.MouseEvent; + + act(() => { + result.current.handleModeChange(mockEvent, null); + }); + + expect(result.current.mode).toBe('interactive'); + }); + + it('should clear failure state when switching to interactive after failure', () => { + const { result } = renderHook(() => useTerminalModalState()); + + const mockEvent = {} as React.MouseEvent; + + // First, trigger fallback + act(() => { + result.current.handleFallback('Error'); + }); + + expect(result.current.interactiveFailed).toBe(true); + + // Then switch back to interactive + act(() => { + result.current.handleModeChange(mockEvent, 'interactive'); + }); + + expect(result.current.mode).toBe('interactive'); + expect(result.current.interactiveFailed).toBe(false); + expect(result.current.fallbackReason).toBe(''); + }); + + it('should handle retry interactive', () => { + const { result } = renderHook(() => useTerminalModalState()); + + // First, trigger fallback + act(() => { + result.current.handleFallback('Connection timeout'); + }); + + // Then retry + act(() => { + result.current.handleRetryInteractive(); + }); + + expect(result.current.mode).toBe('interactive'); + expect(result.current.interactiveFailed).toBe(false); + expect(result.current.fallbackReason).toBe(''); + expect(result.current.showFallbackNotification).toBe(false); + }); + + it('should reset all state', () => { + const { result } = renderHook(() => useTerminalModalState()); + + // Set some state + act(() => { + result.current.handleFallback('Error'); + }); + + expect(result.current.mode).toBe('simple'); + + // Reset + act(() => { + result.current.reset(); + }); + + expect(result.current.mode).toBe('interactive'); + expect(result.current.interactiveFailed).toBe(false); + expect(result.current.fallbackReason).toBe(''); + expect(result.current.showFallbackNotification).toBe(false); + }); + + it('should handle mobile detection', () => { + const { result } = renderHook(() => useTerminalModalState()); + + expect(result.current.isMobile).toBe(false); + }); +}); diff --git a/frontend/lib/hooks/useDashboard.ts b/frontend/lib/hooks/useDashboard.ts new file mode 100644 index 0000000..fd5b86a --- /dev/null +++ b/frontend/lib/hooks/useDashboard.ts @@ -0,0 +1,57 @@ +import { useRouter } from 'next/navigation'; +import { useMediaQuery, useTheme } from '@mui/material'; +import { useAppDispatch } from '@/lib/store/hooks'; +import { logout as logoutAction } from '@/lib/store/authSlice'; +import { useAuthRedirect } from './useAuthRedirect'; +import { useContainerList } from './useContainerList'; +import { useTerminalModal } from './useTerminalModal'; + +/** + * Comprehensive hook for managing Dashboard page state and logic + * Combines authentication, container management, and terminal modal + */ +export function useDashboard() { + const dispatch = useAppDispatch(); + const router = useRouter(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const { isAuthenticated, loading: authLoading } = useAuthRedirect('/'); + const { containers, isRefreshing, isLoading, error, refreshContainers } = useContainerList(isAuthenticated); + const { selectedContainer, isTerminalOpen, openTerminal, closeTerminal } = useTerminalModal(); + + const handleLogout = async () => { + await dispatch(logoutAction()); + router.push('/'); + }; + + const isInitialLoading = authLoading || isLoading; + const hasContainers = containers.length > 0; + const showEmptyState = !isInitialLoading && !hasContainers; + + return { + // Authentication + isAuthenticated, + authLoading, + handleLogout, + + // Container list + containers, + isRefreshing, + isLoading, + error, + refreshContainers, + + // Terminal modal + selectedContainer, + isTerminalOpen, + openTerminal, + closeTerminal, + + // UI state + isMobile, + isInitialLoading, + hasContainers, + showEmptyState, + }; +} diff --git a/frontend/lib/hooks/useTerminalModalState.ts b/frontend/lib/hooks/useTerminalModalState.ts new file mode 100644 index 0000000..7b8cf77 --- /dev/null +++ b/frontend/lib/hooks/useTerminalModalState.ts @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { useMediaQuery, useTheme } from '@mui/material'; + +/** + * Comprehensive hook for managing TerminalModal state + * Handles mode switching, fallback logic, and UI state + */ +export function useTerminalModalState() { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const [mode, setMode] = useState<'simple' | 'interactive'>('interactive'); + const [interactiveFailed, setInteractiveFailed] = useState(false); + const [fallbackReason, setFallbackReason] = useState(''); + const [showFallbackNotification, setShowFallbackNotification] = useState(false); + + const handleFallback = (reason: string) => { + console.warn('Falling back to simple mode:', reason); + setInteractiveFailed(true); + setFallbackReason(reason); + setMode('simple'); + setShowFallbackNotification(false); + }; + + const handleModeChange = ( + event: React.MouseEvent, + newMode: 'simple' | 'interactive' | null, + ) => { + if (newMode !== null) { + if (newMode === 'interactive' && interactiveFailed) { + setInteractiveFailed(false); + setFallbackReason(''); + } + setMode(newMode); + } + }; + + const handleRetryInteractive = () => { + setInteractiveFailed(false); + setFallbackReason(''); + setShowFallbackNotification(false); + setMode('interactive'); + }; + + const reset = () => { + setMode('interactive'); + setInteractiveFailed(false); + setFallbackReason(''); + setShowFallbackNotification(false); + }; + + return { + isMobile, + mode, + interactiveFailed, + fallbackReason, + showFallbackNotification, + handleFallback, + handleModeChange, + handleRetryInteractive, + reset, + }; +}