From 96e4a3dce051239abd23745b7ddc92e70719c4db Mon Sep 17 00:00:00 2001 From: phertyameen Date: Mon, 27 Jul 2026 13:34:04 +0100 Subject: [PATCH] feat: add error boundaries, loading states, typecheck, and testing conventions Closes #1009 Closes #1010 Closes #1019 Closes #1020 - FE-67: Added typecheck script (tsc --noEmit) and CI typecheck step - FE-68: Created renderWithProviders test utility, component test reference examples, updated test-setup - FE-77: Added error boundaries at global, locale, protected, and admin route levels with i18n support - FE-78: Added reusable Skeleton component with prefers-reduced-motion support, loading states for protected, reports, and admin routes --- .github/workflows/ci.yml | 3 + .../app/[locale]/(protected)/admin/error.tsx | 40 +++++++++ .../[locale]/(protected)/admin/loading.tsx | 19 ++++ frontend/app/[locale]/(protected)/error.tsx | 40 +++++++++ frontend/app/[locale]/(protected)/loading.tsx | 18 ++++ .../[locale]/(protected)/reports/loading.tsx | 15 ++++ frontend/app/[locale]/error.tsx | 40 +++++++++ frontend/app/global-error.tsx | 39 +++++++++ .../cmmty/component-test-example.test.tsx | 86 +++++++++++++++++++ frontend/cmmty/test-setup.ts | 11 ++- frontend/cmmty/test-utils.tsx | 62 +++++++++++++ frontend/components/Skeleton.tsx | 33 +++++++ frontend/messages/en.json | 6 ++ frontend/messages/es.json | 6 ++ frontend/messages/fr.json | 6 ++ frontend/package.json | 2 + 16 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 frontend/app/[locale]/(protected)/admin/error.tsx create mode 100644 frontend/app/[locale]/(protected)/admin/loading.tsx create mode 100644 frontend/app/[locale]/(protected)/error.tsx create mode 100644 frontend/app/[locale]/(protected)/loading.tsx create mode 100644 frontend/app/[locale]/(protected)/reports/loading.tsx create mode 100644 frontend/app/[locale]/error.tsx create mode 100644 frontend/app/global-error.tsx create mode 100644 frontend/cmmty/component-test-example.test.tsx create mode 100644 frontend/cmmty/test-utils.tsx create mode 100644 frontend/components/Skeleton.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d91874b..d9538390 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: - name: Lint run: npm run lint + - name: Typecheck + run: npm run typecheck + - name: Build run: npm run build env: diff --git a/frontend/app/[locale]/(protected)/admin/error.tsx b/frontend/app/[locale]/(protected)/admin/error.tsx new file mode 100644 index 00000000..7a0daac1 --- /dev/null +++ b/frontend/app/[locale]/(protected)/admin/error.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; + +export default function AdminError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations("errors"); + + useEffect(() => { + console.error("Admin route error:", error); + }, [error]); + + return ( +
+

{t("title")}

+

{t("description")}

+
+ + + {t("goHome")} + +
+
+ ); +} diff --git a/frontend/app/[locale]/(protected)/admin/loading.tsx b/frontend/app/[locale]/(protected)/admin/loading.tsx new file mode 100644 index 00000000..83294e1b --- /dev/null +++ b/frontend/app/[locale]/(protected)/admin/loading.tsx @@ -0,0 +1,19 @@ +import Skeleton from "@/components/Skeleton"; + +export default function AdminLoading() { + return ( +
+ +
+ + + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/app/[locale]/(protected)/error.tsx b/frontend/app/[locale]/(protected)/error.tsx new file mode 100644 index 00000000..ed8f2d65 --- /dev/null +++ b/frontend/app/[locale]/(protected)/error.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; + +export default function ProtectedError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations("errors"); + + useEffect(() => { + console.error("Protected route error:", error); + }, [error]); + + return ( +
+

{t("title")}

+

{t("description")}

+
+ + + {t("goHome")} + +
+
+ ); +} diff --git a/frontend/app/[locale]/(protected)/loading.tsx b/frontend/app/[locale]/(protected)/loading.tsx new file mode 100644 index 00000000..4b4929ec --- /dev/null +++ b/frontend/app/[locale]/(protected)/loading.tsx @@ -0,0 +1,18 @@ +import Skeleton from "@/components/Skeleton"; + +export default function ProtectedLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+ ); +} diff --git a/frontend/app/[locale]/(protected)/reports/loading.tsx b/frontend/app/[locale]/(protected)/reports/loading.tsx new file mode 100644 index 00000000..f4552dd4 --- /dev/null +++ b/frontend/app/[locale]/(protected)/reports/loading.tsx @@ -0,0 +1,15 @@ +import Skeleton from "@/components/Skeleton"; + +export default function ReportsLoading() { + return ( +
+
+ + +
+ + + +
+ ); +} diff --git a/frontend/app/[locale]/error.tsx b/frontend/app/[locale]/error.tsx new file mode 100644 index 00000000..e5e9044a --- /dev/null +++ b/frontend/app/[locale]/error.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; + +export default function LocaleError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations("errors"); + + useEffect(() => { + console.error("Route error:", error); + }, [error]); + + return ( +
+

{t("title")}

+

{t("description")}

+
+ + + {t("goHome")} + +
+
+ ); +} diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx new file mode 100644 index 00000000..62bf04b4 --- /dev/null +++ b/frontend/app/global-error.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { Link } from "@/i18n/navigation"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Global error:", error); + }, [error]); + + return ( +
+

Something went wrong

+

+ An unexpected error occurred. Please try again. +

+
+ + + Go back home + +
+
+ ); +} diff --git a/frontend/cmmty/component-test-example.test.tsx b/frontend/cmmty/component-test-example.test.tsx new file mode 100644 index 00000000..1bf05490 --- /dev/null +++ b/frontend/cmmty/component-test-example.test.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "./test-utils"; + +function SimpleButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +function DataDisplay({ value, label }: { value: string | number; label: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Counter() { + const [count, setCount] = React.useState(0); + return ( +
+

{count}

+ + +
+ ); +} + +describe("Component testing reference patterns", () => { + describe("Simple component test", () => { + it("renders a button with the correct label", () => { + render( {}} />); + expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument(); + }); + + it("calls onClick when button is clicked", async () => { + const user = userEvent.setup(); + const handleClick = jest.fn(); + render(); + await user.click(screen.getByRole("button", { name: /click me/i })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + }); + + describe("Data display component test", () => { + it("renders label and value", () => { + render(); + expect(screen.getByText("Score")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + }); + + it("renders numeric values", () => { + render(); + expect(screen.getByText("99")).toBeInTheDocument(); + }); + }); + + describe("Form/interaction component test", () => { + it("increments counter on click", async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByTestId("count")).toHaveTextContent("0"); + await user.click(screen.getByRole("button", { name: /increment/i })); + expect(screen.getByTestId("count")).toHaveTextContent("1"); + }); + + it("decrements counter on click", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /decrement/i })); + expect(screen.getByTestId("count")).toHaveTextContent("-1"); + }); + }); + + describe("Provider-wrapped component test", () => { + it("renders with intl provider", () => { + renderWithProviders( {}} />); + expect(screen.getByRole("button", { name: /test/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/cmmty/test-setup.ts b/frontend/cmmty/test-setup.ts index c0aa0911..c1c6baf6 100644 --- a/frontend/cmmty/test-setup.ts +++ b/frontend/cmmty/test-setup.ts @@ -1 +1,10 @@ -// Jest setup file for cmmty tests +import "@testing-library/jest-dom"; + +// Silence next-intl warnings in test output +beforeEach(() => { + jest.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); diff --git a/frontend/cmmty/test-utils.tsx b/frontend/cmmty/test-utils.tsx new file mode 100644 index 00000000..0ad14131 --- /dev/null +++ b/frontend/cmmty/test-utils.tsx @@ -0,0 +1,62 @@ +import React, { ReactElement } from "react"; +import { render, RenderOptions } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; + +const testMessages = { + common: { + appName: "SMALDA", + loading: "Loading…", + save: "Save", + cancel: "Cancel", + previous: "Previous", + next: "Next", + }, + nav: { + dashboard: "Dashboard", + documents: "Documents", + users: "Users", + settings: "Settings", + signOut: "Sign out", + }, + errors: { + title: "Something went wrong", + description: "An unexpected error occurred.", + tryAgain: "Try again", + goHome: "Go back home", + }, + auth: { + login: { + title: "Sign in to SMALDA", + submit: "Sign in", + submitting: "Signing in…", + }, + }, + dashboard: { + title: "Dashboard", + welcome: "Welcome back", + subtitle: "Here's an overview.", + stats: { total: "Total", verified: "Verified", flagged: "Flagged", pending: "Pending" }, + }, +}; + +interface CustomRenderOptions extends Omit { + locale?: string; +} + +function AllProviders({ children, locale = "en" }: { children: React.ReactNode; locale?: string }) { + return ( + + {children} + + ); +} + +export function renderWithProviders(ui: ReactElement, options: CustomRenderOptions = {}) { + const { locale, ...renderOptions } = options; + return render(ui, { + wrapper: ({ children }) => {children}, + ...renderOptions, + }); +} + +export { testMessages }; diff --git a/frontend/components/Skeleton.tsx b/frontend/components/Skeleton.tsx new file mode 100644 index 00000000..0dfbe31a --- /dev/null +++ b/frontend/components/Skeleton.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface SkeletonProps { + className?: string; + width?: string; + height?: string; +} + +export default function Skeleton({ className = "", width, height }: SkeletonProps) { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReducedMotion(mq.matches); + + const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + return ( +
+ Loading… +
+ ); +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 069d1cb1..d33c0f4f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -109,5 +109,11 @@ "title": "Language", "description": "Choose the language used across the interface and in the emails we send you." } + }, + "errors": { + "title": "Something went wrong", + "description": "An unexpected error occurred. Please try again.", + "tryAgain": "Try again", + "goHome": "Go back home" } } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 6440bfe2..404c372e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -109,5 +109,11 @@ "title": "Idioma", "description": "Elige el idioma utilizado en la interfaz y en los correos que te enviamos." } + }, + "errors": { + "title": "Algo salió mal", + "description": "Ocurrió un error inesperado. Por favor, inténtalo de nuevo.", + "tryAgain": "Intentar de nuevo", + "goHome": "Volver al inicio" } } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index a7047db8..bd2a3b09 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -109,5 +109,11 @@ "title": "Langue", "description": "Choisissez la langue utilisée dans l'interface et dans les e-mails que nous vous envoyons." } + }, + "errors": { + "title": "Une erreur est survenue", + "description": "Une erreur inattendue s'est produite. Veuillez réessayer.", + "tryAgain": "Réessayer", + "goHome": "Retour à l'accueil" } } diff --git a/frontend/package.json b/frontend/package.json index fab73221..2992f1b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "typecheck": "tsc --noEmit", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" @@ -33,6 +34,7 @@ "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.6.1", "@types/leaflet": "^1.9.20", "@types/node": "^20", "@types/react": "^19.1.12",