From ec592b7f5e68f9a002de5a1e447382d385c537aa Mon Sep 17 00:00:00 2001 From: FadyGergesRezk <84906847+FadyGergesRezk@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:16:08 +0200 Subject: [PATCH 1/5] feat: add global error boundary and 404 page --- .../src/__tests__/ErrorBoundary.test.tsx | 127 ++++++++++++++++++ .../src/__tests__/NotFoundPage.test.tsx | 61 +++++++++ web-client/src/app/ErrorBoundary.tsx | 61 +++++++++ web-client/src/app/pages/NotFoundPage.tsx | 23 ++++ web-client/src/app/router/routes.tsx | 2 + web-client/src/main.tsx | 13 +- 6 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 web-client/src/__tests__/ErrorBoundary.test.tsx create mode 100644 web-client/src/__tests__/NotFoundPage.test.tsx create mode 100644 web-client/src/app/ErrorBoundary.tsx create mode 100644 web-client/src/app/pages/NotFoundPage.tsx 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..9cee503 --- /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 "Page not found" text in the heading', async () => { + await render() + + const heading = container.querySelector('h1') + + expect(heading?.textContent).toContain('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..7959354 --- /dev/null +++ b/web-client/src/app/ErrorBoundary.tsx @@ -0,0 +1,61 @@ +import { Component, Fragment, type ErrorInfo, type PropsWithChildren } from 'react' +import { router } from '@/app/router/routes' +import { Button } from '@/components/ui/button' +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' + +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 ( +
+ + + Something went wrong + + An unexpected error occurred. Please try again. + + + + + + + +
+ ) + } +} diff --git a/web-client/src/app/pages/NotFoundPage.tsx b/web-client/src/app/pages/NotFoundPage.tsx new file mode 100644 index 0000000..a6d3d50 --- /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 ( +
+
+

+ 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/main.tsx b/web-client/src/main.tsx index b78d8e9..7d2aeb6 100644 --- a/web-client/src/main.tsx +++ b/web-client/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { AxiosError } from 'axios' +import { GlobalErrorBoundary } from '@/app/ErrorBoundary' import { ThemeProvider } from '@/app/theme/ThemeProvider' import AuthenticatedApp from './AuthenticatedApp.tsx' import '@/index.css' @@ -28,10 +29,12 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + , ) From 38ca5a457551c39cdbc62e0f79f81a37aeb3b9de Mon Sep 17 00:00:00 2001 From: FadyGergesRezk <84906847+FadyGergesRezk@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:32:41 +0200 Subject: [PATCH 2/5] refactor: extract shared ErrorCard and unify error boundary placement --- web-client/src/AuthenticatedApp.tsx | 46 +++++++------------- web-client/src/app/ErrorBoundary.tsx | 44 ++++++------------- web-client/src/components/ui/ErrorCard.tsx | 50 ++++++++++++++++++++++ web-client/src/main.tsx | 13 ++---- 4 files changed, 83 insertions(+), 70 deletions(-) create mode 100644 web-client/src/components/ui/ErrorCard.tsx diff --git a/web-client/src/AuthenticatedApp.tsx b/web-client/src/AuthenticatedApp.tsx index 8d152b1..cd12634 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 } 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/app/ErrorBoundary.tsx b/web-client/src/app/ErrorBoundary.tsx index 7959354..e44d0da 100644 --- a/web-client/src/app/ErrorBoundary.tsx +++ b/web-client/src/app/ErrorBoundary.tsx @@ -1,7 +1,6 @@ import { Component, Fragment, type ErrorInfo, type PropsWithChildren } from 'react' import { router } from '@/app/router/routes' -import { Button } from '@/components/ui/button' -import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { ErrorCard } from '@/components/ui/ErrorCard' type GlobalErrorBoundaryState = { hasError: boolean @@ -15,9 +14,7 @@ export class GlobalErrorBoundary extends Component { - return { - hasError: true, - } + return { hasError: true } } componentDidCatch(error: Error, errorInfo: ErrorInfo) { @@ -30,32 +27,17 @@ export class GlobalErrorBoundary extends Component - - - Something went wrong - - An unexpected error occurred. Please try again. - - - - - - - - + void router.navigate('/') }, + { + label: 'Try again', + onClick: () => this.setState((s) => ({ hasError: false, resetKey: s.resetKey + 1 })), + }, + ]} + /> ) } } 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 7d2aeb6..5a9254b 100644 --- a/web-client/src/main.tsx +++ b/web-client/src/main.tsx @@ -1,8 +1,7 @@ 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 { GlobalErrorBoundary } from '@/app/ErrorBoundary' import { ThemeProvider } from '@/app/theme/ThemeProvider' import AuthenticatedApp from './AuthenticatedApp.tsx' import '@/index.css' @@ -29,12 +28,8 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - - - - - - - + + + , ) From 870bb7a7ce584ef11db38c510dc1e9da93eec5af Mon Sep 17 00:00:00 2001 From: FadyGergesRezk <84906847+FadyGergesRezk@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:54:49 +0200 Subject: [PATCH 3/5] feat: enhance AuthenticatedApp with QueryClient and update NotFoundPage heading --- web-client/src/__tests__/AuthenticatedApp.test.tsx | 7 +++++-- web-client/src/__tests__/NotFoundPage.test.tsx | 4 ++-- web-client/src/app/pages/NotFoundPage.tsx | 8 ++++---- web-client/src/app/theme/ThemeToggle.tsx | 13 ++++++++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/web-client/src/__tests__/AuthenticatedApp.test.tsx b/web-client/src/__tests__/AuthenticatedApp.test.tsx index a9e9aa1..cc1f813 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 = { @@ -23,12 +24,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) @@ -43,7 +46,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)) }) @@ -53,7 +56,7 @@ describe('AuthenticatedApp', () => { await act(async () => { root.render( - + , ) await new Promise((r) => setTimeout(r, 0)) diff --git a/web-client/src/__tests__/NotFoundPage.test.tsx b/web-client/src/__tests__/NotFoundPage.test.tsx index 9cee503..7b6f1c8 100644 --- a/web-client/src/__tests__/NotFoundPage.test.tsx +++ b/web-client/src/__tests__/NotFoundPage.test.tsx @@ -27,12 +27,12 @@ describe('NotFoundPage', () => { }) } - it('renders the "Page not found" text in the heading', async () => { + it('renders the "404 - Page not found" text in the heading', async () => { await render() const heading = container.querySelector('h1') - expect(heading?.textContent).toContain('Page not found') + expect(heading?.textContent).toContain('404 - Page not found') }) it('renders a Go back button', async () => { diff --git a/web-client/src/app/pages/NotFoundPage.tsx b/web-client/src/app/pages/NotFoundPage.tsx index a6d3d50..d875ce4 100644 --- a/web-client/src/app/pages/NotFoundPage.tsx +++ b/web-client/src/app/pages/NotFoundPage.tsx @@ -3,12 +3,12 @@ import { Button } from '@/components/ui/button' export function NotFoundPage() { return ( -
+
-

- Page not found +

+ 404 - Page not found

-

+

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

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" > - - - + + + ) } From 2be7190746f29e00b2dbbffa9b8965c5e23807b0 Mon Sep 17 00:00:00 2001 From: FadyGergesRezk <84906847+FadyGergesRezk@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:35:08 +0200 Subject: [PATCH 4/5] test: add createApiClient to keycloak mock in AuthenticatedApp tests --- web-client/src/__tests__/AuthenticatedApp.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web-client/src/__tests__/AuthenticatedApp.test.tsx b/web-client/src/__tests__/AuthenticatedApp.test.tsx index cc1f813..2d41969 100644 --- a/web-client/src/__tests__/AuthenticatedApp.test.tsx +++ b/web-client/src/__tests__/AuthenticatedApp.test.tsx @@ -16,6 +16,7 @@ vi.mock('@/App', () => ({ vi.mock('@/lib/keycloak', () => ({ KEYCLOAK_URL: 'http://keycloak.test', + createApiClient: vi.fn(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() })), default: keycloakMock, })) From 96b9d3f0f543bc998b319b04eb6388987ef5d9a7 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Tue, 16 Jun 2026 10:44:30 +0200 Subject: [PATCH 5/5] fix tests --- web-client/src/__tests__/AuthenticatedApp.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web-client/src/__tests__/AuthenticatedApp.test.tsx b/web-client/src/__tests__/AuthenticatedApp.test.tsx index 8bc408d..b2ee127 100644 --- a/web-client/src/__tests__/AuthenticatedApp.test.tsx +++ b/web-client/src/__tests__/AuthenticatedApp.test.tsx @@ -17,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, }))