diff --git a/web-client/src/AuthenticatedApp.tsx b/web-client/src/AuthenticatedApp.tsx index e3d27b7..57cb1b3 100644 --- a/web-client/src/AuthenticatedApp.tsx +++ b/web-client/src/AuthenticatedApp.tsx @@ -1,10 +1,9 @@ import { useEffect, useRef, useState } from 'react' -import { AlertCircle } from 'lucide-react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { GlobalErrorBoundary } from '@/app/ErrorBoundary' import App from '@/App' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { ErrorCard } from '@/components/ui/ErrorCard' import { LoadingSpinner } from '@/components/ui/LoadingSpinner' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import keycloak, { KEYCLOAK_URL, TOKEN_REFRESH_MIN_VALIDITY_SECONDS } from '@/lib/keycloak' import { AUTH_INIT_TIMEOUT_MS, @@ -17,7 +16,11 @@ function removeSplash() { document.getElementById('splash')?.remove() } -export default function AuthenticatedApp() { +interface AuthenticatedAppProps { + queryClient: QueryClient +} + +export default function AuthenticatedApp({ queryClient }: AuthenticatedAppProps) { const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [authError, setAuthError] = useState(null) const didInitRef = useRef(false) @@ -107,31 +110,14 @@ export default function AuthenticatedApp() { } const { title, description } = messages[authError ?? 'unknown'] - return ( -
- - - {title} - {description} - - - - - Sign-in error - - If this keeps happening, contact support. - - - - - - - -
- ) + return } - return + return ( + + + + + + ) } diff --git a/web-client/src/__tests__/AuthenticatedApp.test.tsx b/web-client/src/__tests__/AuthenticatedApp.test.tsx index 7067530..b2ee127 100644 --- a/web-client/src/__tests__/AuthenticatedApp.test.tsx +++ b/web-client/src/__tests__/AuthenticatedApp.test.tsx @@ -1,5 +1,6 @@ import { StrictMode, act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { QueryClient } from '@tanstack/react-query' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const keycloakMock = { @@ -16,6 +17,7 @@ vi.mock('@/App', () => ({ vi.mock('@/lib/keycloak', () => ({ KEYCLOAK_URL: 'http://keycloak.test', TOKEN_REFRESH_MIN_VALIDITY_SECONDS: 30, + createApiClient: vi.fn(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() })), default: keycloakMock, })) @@ -24,12 +26,14 @@ const { default: AuthenticatedApp } = await import('@/AuthenticatedApp') describe('AuthenticatedApp', () => { let container: HTMLDivElement let root: Root + let queryClient: QueryClient beforeEach(() => { vi.clearAllMocks() keycloakMock.onTokenExpired = undefined keycloakMock.login.mockResolvedValue(undefined) keycloakMock.updateToken.mockResolvedValue(true) + queryClient = new QueryClient() document.body.innerHTML = '
' container = document.getElementById('root') as HTMLDivElement root = createRoot(container) @@ -44,7 +48,7 @@ describe('AuthenticatedApp', () => { async function render() { await act(async () => { - root.render() + root.render() // flush microtasks so the async init chain resolves await new Promise((r) => setTimeout(r, 0)) }) @@ -54,7 +58,7 @@ describe('AuthenticatedApp', () => { await act(async () => { root.render( - + , ) await new Promise((r) => setTimeout(r, 0)) diff --git a/web-client/src/__tests__/ErrorBoundary.test.tsx b/web-client/src/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..8cdd63f --- /dev/null +++ b/web-client/src/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,127 @@ +import { Component, useState, type ReactNode, act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { GlobalErrorBoundary } from '@/app/ErrorBoundary' + +type ThrowingChildProps = { + shouldThrow: boolean +} + +function ThrowingChild({ shouldThrow }: ThrowingChildProps) { + if (shouldThrow) { + throw new Error('Boom') + } + + return
Child rendered
+} + +let remountCount = 0 + +function RemountSensitiveHarness() { + const [mountId] = useState(() => { + remountCount += 1 + return remountCount + }) + + return +} + +type ThrowOnFirstMountProps = { + mountId: number +} + +class ThrowOnFirstMount extends Component { + componentDidMount() { + if (this.props.mountId === 1) { + throw new Error('Boom') + } + } + + render() { + return
Child rendered
+ } +} + +describe('GlobalErrorBoundary', () => { + let container: HTMLDivElement + let root: Root + let consoleErrorSpy: ReturnType + + beforeEach(() => { + document.body.innerHTML = '
' + container = document.getElementById('root') as HTMLDivElement + root = createRoot(container) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + remountCount = 0 + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + consoleErrorSpy.mockRestore() + document.body.innerHTML = '' + }) + + async function render(node: ReactNode) { + await act(async () => { + root.render(node) + }) + } + + async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + } + + it('renders children when no error is thrown', async () => { + await render( + + + , + ) + + expect(container.textContent).toContain('Child rendered') + }) + + it('renders the fallback when a child throws', async () => { + await render( + + + , + ) + + expect(container.textContent).toContain('Go home') + expect(container.textContent).toContain('Try again') + }) + + it('does not render children when in error state', async () => { + await render( + + + , + ) + + expect(container.textContent).not.toContain('Child rendered') + }) + + it('clicking "Try again" remounts the child subtree', async () => { + await render( + + + , + ) + + const tryAgainButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again', + ) + expect(tryAgainButton).toBeTruthy() + + await click(tryAgainButton as Element) + + expect(container.textContent).toContain('Child rendered') + expect(container.textContent).not.toContain('Try again') + expect(remountCount).toBe(2) + }) +}) diff --git a/web-client/src/__tests__/NotFoundPage.test.tsx b/web-client/src/__tests__/NotFoundPage.test.tsx new file mode 100644 index 0000000..7b6f1c8 --- /dev/null +++ b/web-client/src/__tests__/NotFoundPage.test.tsx @@ -0,0 +1,61 @@ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NotFoundPage } from '@/app/pages/NotFoundPage' + +describe('NotFoundPage', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '
' + container = document.getElementById('root') as HTMLDivElement + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + document.body.innerHTML = '' + }) + + async function render() { + await act(async () => { + root.render() + }) + } + + it('renders the "404 - Page not found" text in the heading', async () => { + await render() + + const heading = container.querySelector('h1') + + expect(heading?.textContent).toContain('404 - Page not found') + }) + + it('renders a Go back button', async () => { + await render() + + const button = container.querySelector('button') + + expect(button?.textContent).toBe('Go back') + }) + + it('clicking Go back calls window.history.back()', async () => { + const backSpy = vi.spyOn(window.history, 'back').mockImplementation(() => {}) + + await render() + + const button = container.querySelector('button') + + expect(button).not.toBeNull() + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(backSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web-client/src/app/ErrorBoundary.tsx b/web-client/src/app/ErrorBoundary.tsx new file mode 100644 index 0000000..e44d0da --- /dev/null +++ b/web-client/src/app/ErrorBoundary.tsx @@ -0,0 +1,43 @@ +import { Component, Fragment, type ErrorInfo, type PropsWithChildren } from 'react' +import { router } from '@/app/router/routes' +import { ErrorCard } from '@/components/ui/ErrorCard' + +type GlobalErrorBoundaryState = { + hasError: boolean + resetKey: number +} + +export class GlobalErrorBoundary extends Component { + state: GlobalErrorBoundaryState = { + hasError: false, + resetKey: 0, + } + + static getDerivedStateFromError(): Pick { + return { hasError: true } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('GlobalErrorBoundary caught an error', error, errorInfo) + } + + render() { + if (!this.state.hasError) { + return {this.props.children} + } + + return ( + void router.navigate('/') }, + { + label: 'Try again', + onClick: () => this.setState((s) => ({ hasError: false, resetKey: s.resetKey + 1 })), + }, + ]} + /> + ) + } +} diff --git a/web-client/src/app/pages/NotFoundPage.tsx b/web-client/src/app/pages/NotFoundPage.tsx new file mode 100644 index 0000000..d875ce4 --- /dev/null +++ b/web-client/src/app/pages/NotFoundPage.tsx @@ -0,0 +1,23 @@ +import { router } from '@/app/router/routes' +import { Button } from '@/components/ui/button' + +export function NotFoundPage() { + return ( +
+
+

+ 404 - Page not found +

+

+ The page you're looking for doesn't exist. +

+
+ + +
+ ) +} diff --git a/web-client/src/app/router/routes.tsx b/web-client/src/app/router/routes.tsx index 7899155..668fe74 100644 --- a/web-client/src/app/router/routes.tsx +++ b/web-client/src/app/router/routes.tsx @@ -7,6 +7,7 @@ import { LettersPage } from '@/features/letters' import { OrganizationPage } from '@/features/organization' import { FeedbackPage } from '@/features/feedback' import { HelperPage } from '@/features/helper' +import { NotFoundPage } from '@/app/pages/NotFoundPage' export const router = createBrowserRouter([ { @@ -21,6 +22,7 @@ export const router = createBrowserRouter([ { path: 'organization', element: }, { path: 'feedback', element: }, { path: 'helper', element: }, + { path: '*', element: }, ], }, ]) diff --git a/web-client/src/app/theme/ThemeToggle.tsx b/web-client/src/app/theme/ThemeToggle.tsx index 88281c6..0abeef4 100644 --- a/web-client/src/app/theme/ThemeToggle.tsx +++ b/web-client/src/app/theme/ThemeToggle.tsx @@ -1,6 +1,13 @@ import { Monitor, Moon, Sun } from 'lucide-react' import { useTheme } from './useTheme' import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +const themeIconClassName = (isActive: boolean, inactiveRotationClass: string) => + cn( + 'size-4 transition-all duration-200', + isActive ? 'scale-100 rotate-0 opacity-100' : `absolute scale-0 ${inactiveRotationClass} opacity-0`, + ) export function ThemeToggle() { const { theme, toggleTheme } = useTheme() @@ -14,9 +21,9 @@ export function ThemeToggle() { title={`Toggle theme (current: ${theme})`} className="relative border border-border bg-background/70" > - - - + + + ) } diff --git a/web-client/src/components/ui/ErrorCard.tsx b/web-client/src/components/ui/ErrorCard.tsx new file mode 100644 index 0000000..7569c37 --- /dev/null +++ b/web-client/src/components/ui/ErrorCard.tsx @@ -0,0 +1,50 @@ +import { AlertCircle } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' + +interface ErrorCardAction { + label: string + onClick: () => void +} + +interface ErrorCardProps { + title: string + description: string + alertTitle?: string + alertDescription?: string + actions?: ErrorCardAction[] +} + +export function ErrorCard({ + title, + description, + alertTitle = 'Error', + alertDescription = 'If this keeps happening, contact support.', + actions = [{ label: 'Try again', onClick: () => window.location.reload() }], +}: ErrorCardProps) { + return ( +
+ + + {title} + {description} + + + + + {alertTitle} + {alertDescription} + + + + {actions.map((action) => ( + + ))} + + +
+ ) +} diff --git a/web-client/src/main.tsx b/web-client/src/main.tsx index b78d8e9..5a9254b 100644 --- a/web-client/src/main.tsx +++ b/web-client/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient } from '@tanstack/react-query' import { AxiosError } from 'axios' import { ThemeProvider } from '@/app/theme/ThemeProvider' import AuthenticatedApp from './AuthenticatedApp.tsx' @@ -29,9 +29,7 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - - - + , )