Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 16 additions & 30 deletions web-client/src/AuthenticatedApp.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<AuthError | null>(null)
const didInitRef = useRef(false)
Expand Down Expand Up @@ -107,31 +110,14 @@ export default function AuthenticatedApp() {
}
const { title, description } = messages[authError ?? 'unknown']

return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Sign-in error</AlertTitle>
<AlertDescription>
If this keeps happening, contact support.
</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button className="w-full" onClick={() => window.location.reload()}>
Try again
</Button>
</CardFooter>
</Card>
</div>
)
return <ErrorCard title={title} description={description} alertTitle="Sign-in error" />
}

return <App />
return (
<QueryClientProvider client={queryClient}>
<GlobalErrorBoundary>
<App />
</GlobalErrorBoundary>
</QueryClientProvider>
)
}
8 changes: 6 additions & 2 deletions web-client/src/__tests__/AuthenticatedApp.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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,
}))

Expand All @@ -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 = '<div id="root"></div><div id="splash"></div>'
container = document.getElementById('root') as HTMLDivElement
root = createRoot(container)
Expand All @@ -44,7 +48,7 @@ describe('AuthenticatedApp', () => {

async function render() {
await act(async () => {
root.render(<AuthenticatedApp />)
root.render(<AuthenticatedApp queryClient={queryClient} />)
// flush microtasks so the async init chain resolves
await new Promise((r) => setTimeout(r, 0))
})
Expand All @@ -54,7 +58,7 @@ describe('AuthenticatedApp', () => {
await act(async () => {
root.render(
<StrictMode>
<AuthenticatedApp />
<AuthenticatedApp queryClient={queryClient} />
</StrictMode>,
)
await new Promise((r) => setTimeout(r, 0))
Expand Down
127 changes: 127 additions & 0 deletions web-client/src/__tests__/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>Child rendered</div>
}

let remountCount = 0

function RemountSensitiveHarness() {
const [mountId] = useState(() => {
remountCount += 1
return remountCount
})

return <ThrowOnFirstMount mountId={mountId} />
}

type ThrowOnFirstMountProps = {
mountId: number
}

class ThrowOnFirstMount extends Component<ThrowOnFirstMountProps> {
componentDidMount() {
if (this.props.mountId === 1) {
throw new Error('Boom')
}
}

render() {
return <div>Child rendered</div>
}
}

describe('GlobalErrorBoundary', () => {
let container: HTMLDivElement
let root: Root
let consoleErrorSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>'
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(
<GlobalErrorBoundary>
<ThrowingChild shouldThrow={false} />
</GlobalErrorBoundary>,
)

expect(container.textContent).toContain('Child rendered')
})

it('renders the fallback when a child throws', async () => {
await render(
<GlobalErrorBoundary>
<ThrowingChild shouldThrow />
</GlobalErrorBoundary>,
)

expect(container.textContent).toContain('Go home')
expect(container.textContent).toContain('Try again')
})

it('does not render children when in error state', async () => {
await render(
<GlobalErrorBoundary>
<ThrowingChild shouldThrow />
</GlobalErrorBoundary>,
)

expect(container.textContent).not.toContain('Child rendered')
})

it('clicking "Try again" remounts the child subtree', async () => {
await render(
<GlobalErrorBoundary>
<RemountSensitiveHarness />
</GlobalErrorBoundary>,
)

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)
})
})
61 changes: 61 additions & 0 deletions web-client/src/__tests__/NotFoundPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = '<div id="root"></div>'
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(<NotFoundPage />)
})
}

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)
})
})
43 changes: 43 additions & 0 deletions web-client/src/app/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren, GlobalErrorBoundaryState> {
state: GlobalErrorBoundaryState = {
hasError: false,
resetKey: 0,
}

static getDerivedStateFromError(): Pick<GlobalErrorBoundaryState, 'hasError'> {
return { hasError: true }
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('GlobalErrorBoundary caught an error', error, errorInfo)
}

render() {
if (!this.state.hasError) {
return <Fragment key={this.state.resetKey}>{this.props.children}</Fragment>
}

return (
<ErrorCard
title="Something went wrong"
description="An unexpected error occurred. Please try again."
actions={[
{ label: 'Go home', onClick: () => void router.navigate('/') },
{
label: 'Try again',
onClick: () => this.setState((s) => ({ hasError: false, resetKey: s.resetKey + 1 })),
},
]}
/>
)
}
}
23 changes: 23 additions & 0 deletions web-client/src/app/pages/NotFoundPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { router } from '@/app/router/routes'
import { Button } from '@/components/ui/button'

export function NotFoundPage() {
return (
<div className="flex min-h-[calc(100dvh-3rem)] flex-col items-center justify-center gap-4 text-center">
<div className="space-y-2">
<h1 className="font-display text-display-md uppercase tracking-wide text-balance text-foreground">
404 - Page not found
</h1>
<p className="text-body-sm text-muted-foreground sm:text-body">
The page you&apos;re looking for doesn&apos;t exist.
</p>
</div>
<Button variant="outline" onClick={() => window.history.back()}>
Go back
</Button>
<Button onClick={() => void router.navigate('/')}>
Go home
</Button>
</div>
)
}
2 changes: 2 additions & 0 deletions web-client/src/app/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand All @@ -21,6 +22,7 @@ export const router = createBrowserRouter([
{ path: 'organization', element: <OrganizationPage /> },
{ path: 'feedback', element: <FeedbackPage /> },
{ path: 'helper', element: <HelperPage /> },
{ path: '*', element: <NotFoundPage /> },
],
},
])
Loading
Loading