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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ jobs:
- name: Lint
run: npm run lint

- name: Typecheck
run: npm run typecheck

- name: Build
run: npm run build
env:
Expand Down
40 changes: 40 additions & 0 deletions frontend/app/[locale]/(protected)/admin/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-3xl font-bold text-gray-900">{t("title")}</h1>
<p className="text-sm text-gray-500">{t("description")}</p>
<div className="flex gap-3">
<button
onClick={() => reset()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{t("tryAgain")}
</button>
<Link
href="/"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400"
>
{t("goHome")}
</Link>
</div>
</main>
);
}
19 changes: 19 additions & 0 deletions frontend/app/[locale]/(protected)/admin/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Skeleton from "@/components/Skeleton";

export default function AdminLoading() {
return (
<main className="p-6 space-y-6" role="status" aria-label="Loading admin page">
<Skeleton className="h-8 w-48" />
<div className="flex gap-3">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-48" />
</div>
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded" />
))}
</div>
</main>
);
}
40 changes: 40 additions & 0 deletions frontend/app/[locale]/(protected)/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-3xl font-bold text-gray-900">{t("title")}</h1>
<p className="text-sm text-gray-500">{t("description")}</p>
<div className="flex gap-3">
<button
onClick={() => reset()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{t("tryAgain")}
</button>
<Link
href="/"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400"
>
{t("goHome")}
</Link>
</div>
</main>
);
}
18 changes: 18 additions & 0 deletions frontend/app/[locale]/(protected)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Skeleton from "@/components/Skeleton";

export default function ProtectedLoading() {
return (
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6" role="status" aria-label="Loading">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
</div>
<Skeleton className="h-48 rounded-2xl" />
</main>
);
}
15 changes: 15 additions & 0 deletions frontend/app/[locale]/(protected)/reports/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Skeleton from "@/components/Skeleton";

export default function ReportsLoading() {
return (
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6" role="status" aria-label="Loading reports">
<div className="space-y-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-40 rounded-2xl" />
<Skeleton className="h-32 rounded-2xl" />
<Skeleton className="h-64 rounded-2xl" />
</main>
);
}
40 changes: 40 additions & 0 deletions frontend/app/[locale]/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-3xl font-bold text-gray-900">{t("title")}</h1>
<p className="text-sm text-gray-500">{t("description")}</p>
<div className="flex gap-3">
<button
onClick={() => reset()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{t("tryAgain")}
</button>
<Link
href="/"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400"
>
{t("goHome")}
</Link>
</div>
</main>
);
}
39 changes: 39 additions & 0 deletions frontend/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900">Something went wrong</h1>
<p className="text-sm text-gray-500">
An unexpected error occurred. Please try again.
</p>
<div className="flex gap-3">
<button
onClick={() => reset()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Try again
</button>
<Link
href="/"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400"
>
Go back home
</Link>
</div>
</main>
);
}
86 changes: 86 additions & 0 deletions frontend/cmmty/component-test-example.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button onClick={onClick} className="rounded bg-blue-600 px-4 py-2 text-white">
{label}
</button>
);
}

function DataDisplay({ value, label }: { value: string | number; label: string }) {
return (
<div>
<dt className="text-sm text-gray-500">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
);
}

function Counter() {
const [count, setCount] = React.useState(0);
return (
<div>
<p data-testid="count">{count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={() => setCount((c) => c - 1)}>Decrement</button>
</div>
);
}

describe("Component testing reference patterns", () => {
describe("Simple component test", () => {
it("renders a button with the correct label", () => {
render(<SimpleButton label="Click me" onClick={() => {}} />);
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(<SimpleButton label="Click me" onClick={handleClick} />);
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(<DataDisplay value="42" label="Score" />);
expect(screen.getByText("Score")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
});

it("renders numeric values", () => {
render(<DataDisplay value={99} label="Count" />);
expect(screen.getByText("99")).toBeInTheDocument();
});
});

describe("Form/interaction component test", () => {
it("increments counter on click", async () => {
const user = userEvent.setup();
render(<Counter />);
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(<Counter />);
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(<SimpleButton label="Test" onClick={() => {}} />);
expect(screen.getByRole("button", { name: /test/i })).toBeInTheDocument();
});
});
});
11 changes: 10 additions & 1 deletion frontend/cmmty/test-setup.ts
Original file line number Diff line number Diff line change
@@ -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();
});
62 changes: 62 additions & 0 deletions frontend/cmmty/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -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<RenderOptions, "wrapper"> {
locale?: string;
}

function AllProviders({ children, locale = "en" }: { children: React.ReactNode; locale?: string }) {
return (
<NextIntlClientProvider locale={locale} messages={testMessages}>
{children}
</NextIntlClientProvider>
);
}

export function renderWithProviders(ui: ReactElement, options: CustomRenderOptions = {}) {
const { locale, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => <AllProviders locale={locale}>{children}</AllProviders>,
...renderOptions,
});
}

export { testMessages };
Loading
Loading