From 9f2e7528851fc3ebb8e9cd8d79a91c22e3094150 Mon Sep 17 00:00:00 2001 From: Henry Chen Date: Mon, 8 Jun 2026 15:37:35 -0400 Subject: [PATCH] JS: Initialize frontend structure --- AGENTS.md | 55 ++++---- js/docs/frontend-structure.md | 131 ++++++++++++++++++ js/src/app/Root.page.tsx | 3 - js/src/app/layouts/AdminLayout.tsx | 19 +++ js/src/app/layouts/AppLayout.tsx | 18 +++ js/src/app/layouts/PublicLayout.tsx | 11 ++ js/src/app/providers/QueryProvider.tsx | 9 ++ js/src/{lib => app/providers}/theme.tsx | 0 js/src/app/router/guards/RequireAdmin.tsx | 17 +++ js/src/app/router/guards/RequireAuth.tsx | 26 ++++ js/src/app/router/router.tsx | 37 +++++ js/src/app/user/admin/Admin.page.tsx | 3 - .../admin/_components/ADD COMPONENTS HERE | 0 js/src/app/user/admin/emails/Emails.page.tsx | 3 - .../emails/_components/ADD COMPONENTS HERE | 0 js/src/app/user/intake/IntakeForm.page.tsx | 3 - .../intake/_components/ADD COMPONENTS HERE | 0 js/src/features/home/Home.page.tsx | 17 +++ js/src/features/sample/Sample.page.test.tsx | 9 ++ js/src/features/sample/Sample.page.tsx | 16 +++ js/src/features/sample/SampleAdmin.page.tsx | 15 ++ js/src/features/sample/api/sample.mock.ts | 11 ++ .../features/sample/api/useSampleMessage.ts | 16 +++ js/src/lib/api/client.ts | 33 +++++ js/src/lib/api/queryClient.ts | 7 + js/src/lib/api/schema.d.ts | 12 ++ js/src/lib/api/useSession.ts | 26 ++++ js/src/lib/queryProvider.tsx | 17 --- js/src/lib/router.tsx | 28 ---- js/src/lib/test/defaults.tsx | 27 ++++ js/src/lib/test/render.tsx | 37 +++++ js/src/lib/test/server.ts | 8 ++ js/src/main.tsx | 10 +- js/tsconfig.app.json | 7 +- js/tsconfig.test.json | 1 + 35 files changed, 545 insertions(+), 87 deletions(-) create mode 100644 js/docs/frontend-structure.md delete mode 100644 js/src/app/Root.page.tsx create mode 100644 js/src/app/layouts/AdminLayout.tsx create mode 100644 js/src/app/layouts/AppLayout.tsx create mode 100644 js/src/app/layouts/PublicLayout.tsx create mode 100644 js/src/app/providers/QueryProvider.tsx rename js/src/{lib => app/providers}/theme.tsx (100%) create mode 100644 js/src/app/router/guards/RequireAdmin.tsx create mode 100644 js/src/app/router/guards/RequireAuth.tsx create mode 100644 js/src/app/router/router.tsx delete mode 100644 js/src/app/user/admin/Admin.page.tsx delete mode 100644 js/src/app/user/admin/_components/ADD COMPONENTS HERE delete mode 100644 js/src/app/user/admin/emails/Emails.page.tsx delete mode 100644 js/src/app/user/admin/emails/_components/ADD COMPONENTS HERE delete mode 100644 js/src/app/user/intake/IntakeForm.page.tsx delete mode 100644 js/src/app/user/intake/_components/ADD COMPONENTS HERE create mode 100644 js/src/features/home/Home.page.tsx create mode 100644 js/src/features/sample/Sample.page.test.tsx create mode 100644 js/src/features/sample/Sample.page.tsx create mode 100644 js/src/features/sample/SampleAdmin.page.tsx create mode 100644 js/src/features/sample/api/sample.mock.ts create mode 100644 js/src/features/sample/api/useSampleMessage.ts create mode 100644 js/src/lib/api/client.ts create mode 100644 js/src/lib/api/queryClient.ts create mode 100644 js/src/lib/api/schema.d.ts create mode 100644 js/src/lib/api/useSession.ts delete mode 100644 js/src/lib/queryProvider.tsx delete mode 100644 js/src/lib/router.tsx create mode 100644 js/src/lib/test/defaults.tsx create mode 100644 js/src/lib/test/render.tsx create mode 100644 js/src/lib/test/server.ts diff --git a/AGENTS.md b/AGENTS.md index 9c437ff..013a0d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,12 @@ patchats/ │ └── main/java/org/patinanetwork/patchats/ │ └── api/ # REST controllers + security config ├── js/ # TypeScript frontend (React + Vite) +│ ├── docs/frontend-structure.md # Frontend folder structure & conventions (READ THIS) │ └── src/ -│ ├── app/ # Pages, organized by route -│ ├── lib/ # Shared setup: router, theme, query provider +│ ├── app/ # App shell: router, guards, layouts, providers +│ ├── features/ # Domains — one folder per bounded context (pages + api + tests) +│ ├── components/ # App-shared UI primitives +│ ├── lib/ # App-shared infra: api client, query, test utils │ └── main.tsx # App entry point ├── Justfile # All common dev commands ├── pom.xml # Maven config (backend deps + build) @@ -25,23 +28,23 @@ patchats/ ## Tech Stack -| Layer | Technology | -|---|---| -| Backend language | Java 25 + Spring Boot 3.x | -| Backend build | Maven (`./mvnw`) | -| Database | PostgreSQL + Flyway migrations | -| Data access | Spring JDBC (plain SQL — no ORM) | -| Auth | Spring Security + OAuth2 (no passwords stored) | -| API docs | SpringDoc / OpenAPI → `/v3/api-docs` | -| Frontend language | TypeScript (strict mode) | -| Frontend framework | React 18, functional components + hooks | -| Frontend build | Vite | -| UI components | Mantine 8 | -| Routing | React Router v6 — routes defined in `js/src/lib/router.tsx` | -| Forms + validation | Mantine Form + Zod (define Zod schema first, derive form from it) | -| Frontend pkg manager | pnpm (use `pnpm`, not `npm`) | -| Secrets | SOPS — never commit plaintext secrets | -| Task runner | `just` — run `just` to see all commands | +| Layer | Technology | +| -------------------- | ---------------------------------------------------------------------- | +| Backend language | Java 25 + Spring Boot 3.x | +| Backend build | Maven (`./mvnw`) | +| Database | PostgreSQL + Flyway migrations | +| Data access | Spring JDBC (plain SQL — no ORM) | +| Auth | Spring Security + OAuth2 (no passwords stored) | +| API docs | SpringDoc / OpenAPI → `/v3/api-docs` | +| Frontend language | TypeScript (strict mode) | +| Frontend framework | React 18, functional components + hooks | +| Frontend build | Vite | +| UI components | Mantine 8 | +| Routing | React Router v6 — central route tree in `js/src/app/router/router.tsx` | +| Forms + validation | Mantine Form + Zod (define Zod schema first, derive form from it) | +| Frontend pkg manager | pnpm (use `pnpm`, not `npm`) | +| Secrets | SOPS — never commit plaintext secrets | +| Task runner | `just` — run `just` to see all commands | --- @@ -54,18 +57,22 @@ See `Justfile` at the repo root for all available commands. ## Code Conventions **Backend** + - Controllers live in `src/main/java/.../api/` and are annotated with `@RestController`. - All responses are wrapped in `ApiResponder`. - Every schema change requires a Flyway migration in `db/`. - OpenAPI annotations (`@Operation`, `@Tag`) must be kept current. -**Frontend** -- Pages live under `js/src/app/` named `.page.tsx`. -- Components for a page go in a `_components/` sibling directory. -- Use Mantine components and `js/src/lib/theme.tsx` for all styling. +**Frontend** — see `js/docs/frontend-structure.md` for the full structure + conventions. + +- Domain-first: feature code lives under `js/src/features//`; pages are `.page.tsx`. +- Permissions live in the router (guards + layouts), not the folder tree. +- Data hooks are domain-owned (`features//api/`); generated types + client live in `js/src/lib/api/`. +- Use Mantine components and `js/src/app/providers/theme.tsx` for styling. - Imports use the `@/` alias for `js/src/`. **Formatting / Linting (enforced in CI)** + - Backend: Spotless (Palantir Java Format) + Checkstyle (`checkstyle.xml`) - Frontend: Prettier + ESLint + Stylelint @@ -77,4 +84,4 @@ Secrets are encrypted with [SOPS](https://github.com/getsops/sops) and committed - `.example.env` documents the environment variables the app expects. - `.sops.yaml` defines the encryption keys and file patterns. -Local development env vars are loaded via `dotenvx` (see `Justfile` commands). \ No newline at end of file + Local development env vars are loaded via `dotenvx` (see `Justfile` commands). diff --git a/js/docs/frontend-structure.md b/js/docs/frontend-structure.md new file mode 100644 index 0000000..2190d7d --- /dev/null +++ b/js/docs/frontend-structure.md @@ -0,0 +1,131 @@ +# Frontend structure & conventions + +How the `js/` frontend (React 18 + Vite + TypeScript) is organized. Read this before adding +pages, features, or data hooks so new code lands in the right place. + +Stack: React Router v6, TanStack Query, Mantine 8, Zod, MSW, `openapi-typescript` (types +generated from the backend's OpenAPI spec). Path alias `@/* → src/*`. ESLint enforces absolute +imports (`@`-prefixed) and import sorting; `/api` is proxied to the Spring backend. + +## The shape + +``` +js/src/ + main.tsx # entry + app/ # APP SHELL — bootstrapping, NOT pages + router/ + router.tsx # central route tree: guards + layouts + page mounts + guards/ + RequireAuth.tsx # redirect to /login if no session + RequireAdmin.tsx # redirect/403 if not admin + layouts/ + PublicLayout.tsx # public chrome + AppLayout.tsx # authed chrome (sidebar, user menu) + AdminLayout.tsx # admin chrome + providers/ + QueryProvider.tsx # TanStack Query provider + theme.tsx # Mantine theme + features/ # DOMAINS — one folder per bounded context + / # e.g. pairings, profile, intake, admin + .page.tsx # a page (flat file until it grows) + / # page-FOLDER, only once the page gains local pieces + .page.tsx + components/ # used only by this page + components/ # domain-shared UI (2+ pages in this domain) + api/ + useX.ts # query/mutation hooks (domain-owned) + schemas.ts # Zod (flat until it grows) + .mock.ts # MSW handlers (+fixtures) → mocks/ folder when it grows + hooks/ # non-data domain hooks (on demand) + types.ts # flat until it grows + components/ # APP-SHARED UI primitives (cross-domain) + lib/ # APP-SHARED, framework-agnostic infra + api/ + schema.d.ts # openapi-typescript generated types (whole backend) + client.ts # typed fetch wrapper over /api + queryClient.ts # QueryClient instance/config + test/ + defaults.tsx # vitest setupFile (wired in vite.config.ts) + server.ts # MSW setupServer composing domain *.mock.ts handlers + render.tsx # custom RTL render wrapping providers + utils/ # generic, domain-agnostic helpers +``` + +## Conventions + +1. **Domain-first, not permission-first.** Folders are organized by domain. Permission is a + routing concern — never encode access (admin/public/authed) in the folder tree. +2. **Permissions live in the router.** Composable nested routes: a guard route (`RequireAuth`, + `RequireAdmin`) wraps a layout route wraps the page. A page's access level and visual frame + each change with a one-line route edit; the page file never moves. +3. **`features/` wrapper.** Everything under `features/` is a domain; everything else (`app/`, + `components/`, `lib/`) is shell or shared infra. +4. **Pages: hybrid granularity.** A page is a single `Name.page.tsx`. It graduates to a folder + (`name/Name.page.tsx` + a local `components/`) only once it accumulates page-local pieces. + Keep the `.page.tsx` suffix inside page-folders — don't use `index.tsx`. +5. **Data layer split.** Global (`lib/api/`): generated OpenAPI types + typed fetch client + + QueryClient. Domain-owned (`features//api/`): per-endpoint query/mutation hooks + Zod + schemas. `useSubmitIntake` belongs to `intake`, not a global pile. +6. **Concern folders, on demand.** A domain has `components/` + `api/`; add `hooks/` / + `utils.ts` / `types.ts` only as they grow (flat file first, folder at 2–3 files). There is + no domain-level `lib/` — `lib/` means app infra only. +7. **No barrels.** Import the specific file (`@/features/pairings/api/usePairings`). This keeps + Vite/HMR and tree-shaking fast and avoids circular-import bugs. +8. **Testing.** Tests colocate beside source (`Name.page.test.tsx`). Shared test infra lives in + `lib/test/`. MSW handlers are domain-owned, named `.mock.ts` in `features//api/` + (parallels `.test.ts`, and is excluded from the prod build); `lib/test/server.ts` composes + them. Promote to a `features//mocks/` folder when fixtures grow. +9. **Central router.** One `app/router/router.tsx` owns the full guard/layout/route tree and + imports page components from domains (thin wiring, not domain logic). It keeps the whole + access map visible in one place. +10. **Graduation rule.** Start at the narrowest scope; promote outward only on the _second real + consumer_: page-local → domain (`features//...`) → app-shared (`src/components`, + `src/lib`). Avoid premature promotion. +11. **Styles colocate.** `Foo.module.css` sits next to `Foo.tsx`. +12. **Cross-domain data access.** A domain's `api/` folder is its public surface — the one place + another domain may import from (never another domain's `components/` or page internals). + Prefer, in order: (a) **backend composition** — have the endpoint embed the fields you need + so no cross-call happens (avoids request waterfalls); (b) for genuine client-side cross-reads, + import the **canonical hook** from `features//api` and reuse its query keys (one shared + cache entry); (c) keep dependencies **one-directional** — a cycle is the signal to extract a + shared piece. When a domain is consumed by **3+ domains** (e.g. the user/profile entity), + promote its _data layer_ to a shared `entities//` tier while its _pages_ stay in + `features//`. + +## Where things go (quick reference) + +| You're adding… | Put it in… | +| ------------------------------------ | ------------------------------------------------------------------------ | +| A new page | `features//.page.tsx` (+ mount in `app/router/router.tsx`) | +| A query/mutation hook | `features//api/useX.ts` | +| A Zod schema for an endpoint | `features//api/schemas.ts` | +| UI used by 2+ pages in one domain | `features//components/` | +| UI used across domains | `src/components/` | +| A request mock for tests | `features//api/.mock.ts` | +| The typed fetch client / QueryClient | `src/lib/api/` | +| A new layout or route guard | `src/app/layouts/` or `src/app/router/guards/` | + +## Decision log + +The reasoning behind the conventions, for anyone tempted to reorganize. + +1. **Domain vs permission as the primary axis → domain.** Permission is a routing concern; one + feature spans permission levels; permissions change (folders would churn); and a folder split + enforces nothing — real gating is the router guard + backend. Rejected top-level + `admin/ public/ authenticated/` dirs. +2. **Page granularity → hybrid (file until it grows).** Promotion is cheap because a page is + imported in exactly one place (the router). Rejected always-folder-per-page. +3. **Domain wrapper → `features/`.** Rejected flat-under-`src/` (domains blend with infra). +4. **Data layer → domain-owned hooks**, thin global (`lib/api/` = generated types + client + + QueryClient). Rejected a global `src/api/` pile. +5. **Domain insides → concern folders on demand.** Rejected a domain-level `lib/` (junk drawer; + name clash with app-infra `lib/`). +6. **Encapsulation → no barrels.** Rejected per-domain `index.ts` (Vite/tree-shaking cost, + circular-import risk). Enforce boundaries with lint instead, if needed. +7. **MSW handlers → domain-owned `.mock.ts`.** Rejected one global `handlers.ts` + (monolith that re-couples domains). +8. **Router → central `router.tsx`.** Rejected domain-contributed route objects for now + (fragments the access map); revisit only if the file becomes unwieldy. +9. **Cross-domain data → backend-composition first, else import the owner's `api/` hook.** + Rejected an upfront `entities/` tier (premature) and per-domain duplication (cache + fragmentation + double fetching). Promote to `entities/` only at 3+ consumers. diff --git a/js/src/app/Root.page.tsx b/js/src/app/Root.page.tsx deleted file mode 100644 index de6dfbc..0000000 --- a/js/src/app/Root.page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function RootPage() { - return <>Hello; -} diff --git a/js/src/app/layouts/AdminLayout.tsx b/js/src/app/layouts/AdminLayout.tsx new file mode 100644 index 0000000..8fdddae --- /dev/null +++ b/js/src/app/layouts/AdminLayout.tsx @@ -0,0 +1,19 @@ +import { AppShell, Badge, Group, Text } from "@mantine/core"; +import { Outlet } from "react-router-dom"; + +/** Chrome for admin pages: the authed header plus an admin marker. */ +export function AdminLayout() { + return ( + + + + PatChats + Admin + + + + + + + ); +} diff --git a/js/src/app/layouts/AppLayout.tsx b/js/src/app/layouts/AppLayout.tsx new file mode 100644 index 0000000..5165214 --- /dev/null +++ b/js/src/app/layouts/AppLayout.tsx @@ -0,0 +1,18 @@ +import { AppShell, Group, Text } from "@mantine/core"; +import { Outlet } from "react-router-dom"; + +/** Chrome for authenticated pages: a header plus the routed page content. */ +export function AppLayout() { + return ( + + + + PatChats + + + + + + + ); +} diff --git a/js/src/app/layouts/PublicLayout.tsx b/js/src/app/layouts/PublicLayout.tsx new file mode 100644 index 0000000..b6d7ba6 --- /dev/null +++ b/js/src/app/layouts/PublicLayout.tsx @@ -0,0 +1,11 @@ +import { Container } from "@mantine/core"; +import { Outlet } from "react-router-dom"; + +/** Chrome for public (unauthenticated) pages, e.g. landing and login. */ +export function PublicLayout() { + return ( + + + + ); +} diff --git a/js/src/app/providers/QueryProvider.tsx b/js/src/app/providers/QueryProvider.tsx new file mode 100644 index 0000000..db5a43e --- /dev/null +++ b/js/src/app/providers/QueryProvider.tsx @@ -0,0 +1,9 @@ +import { queryClient } from "@/lib/api/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode } from "react"; + +export default function QueryProvider({ children }: { children: ReactNode }) { + return ( + {children} + ); +} diff --git a/js/src/lib/theme.tsx b/js/src/app/providers/theme.tsx similarity index 100% rename from js/src/lib/theme.tsx rename to js/src/app/providers/theme.tsx diff --git a/js/src/app/router/guards/RequireAdmin.tsx b/js/src/app/router/guards/RequireAdmin.tsx new file mode 100644 index 0000000..feb7e09 --- /dev/null +++ b/js/src/app/router/guards/RequireAdmin.tsx @@ -0,0 +1,17 @@ +import { useSession } from "@/lib/api/useSession"; +import { Navigate, Outlet } from "react-router-dom"; + +/** + * Route guard: render the nested routes only for an admin. Assumes it is nested + * under RequireAuth, so the session is already resolved here; non-admins are sent + * home. + */ +export function RequireAdmin() { + const { data: session } = useSession(); + + if (!session?.isAdmin) { + return ; + } + + return ; +} diff --git a/js/src/app/router/guards/RequireAuth.tsx b/js/src/app/router/guards/RequireAuth.tsx new file mode 100644 index 0000000..909fe70 --- /dev/null +++ b/js/src/app/router/guards/RequireAuth.tsx @@ -0,0 +1,26 @@ +import { useSession } from "@/lib/api/useSession"; +import { Center, Loader } from "@mantine/core"; +import { Navigate, Outlet } from "react-router-dom"; + +/** + * Route guard: render the nested routes only for an authenticated user. + * While the session is loading, show a spinner; if there is no session, redirect + * to the public home (no /login page exists yet). + */ +export function RequireAuth() { + const { data: session, isPending } = useSession(); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!session) { + return ; + } + + return ; +} diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx new file mode 100644 index 0000000..792873f --- /dev/null +++ b/js/src/app/router/router.tsx @@ -0,0 +1,37 @@ +import { AdminLayout } from "@/app/layouts/AdminLayout"; +import { AppLayout } from "@/app/layouts/AppLayout"; +import { PublicLayout } from "@/app/layouts/PublicLayout"; +import { RequireAdmin } from "@/app/router/guards/RequireAdmin"; +import { RequireAuth } from "@/app/router/guards/RequireAuth"; +import HomePage from "@/features/home/Home.page"; +import SamplePage from "@/features/sample/Sample.page"; +import SampleAdminPage from "@/features/sample/SampleAdmin.page"; +import { createBrowserRouter } from "react-router-dom"; + +export const router = createBrowserRouter([ + // Public: no guard, public chrome. + { + element: , + children: [{ index: true, element: }], + }, + + // Authenticated: guard -> layout -> page. Admin nests a second guard + layout. + { + element: , + children: [ + { + element: , + children: [{ path: "sample", element: }], + }, + { + element: , + children: [ + { + element: , + children: [{ path: "sample/admin", element: }], + }, + ], + }, + ], + }, +]); diff --git a/js/src/app/user/admin/Admin.page.tsx b/js/src/app/user/admin/Admin.page.tsx deleted file mode 100644 index 152ec9a..0000000 --- a/js/src/app/user/admin/Admin.page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function AdminPage() { - return <>; -} diff --git a/js/src/app/user/admin/_components/ADD COMPONENTS HERE b/js/src/app/user/admin/_components/ADD COMPONENTS HERE deleted file mode 100644 index e69de29..0000000 diff --git a/js/src/app/user/admin/emails/Emails.page.tsx b/js/src/app/user/admin/emails/Emails.page.tsx deleted file mode 100644 index e902626..0000000 --- a/js/src/app/user/admin/emails/Emails.page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function EmailsPage() { - return <>; -} diff --git a/js/src/app/user/admin/emails/_components/ADD COMPONENTS HERE b/js/src/app/user/admin/emails/_components/ADD COMPONENTS HERE deleted file mode 100644 index e69de29..0000000 diff --git a/js/src/app/user/intake/IntakeForm.page.tsx b/js/src/app/user/intake/IntakeForm.page.tsx deleted file mode 100644 index bfa9344..0000000 --- a/js/src/app/user/intake/IntakeForm.page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function IntakeFormPage() { - return <>; -} diff --git a/js/src/app/user/intake/_components/ADD COMPONENTS HERE b/js/src/app/user/intake/_components/ADD COMPONENTS HERE deleted file mode 100644 index e69de29..0000000 diff --git a/js/src/features/home/Home.page.tsx b/js/src/features/home/Home.page.tsx new file mode 100644 index 0000000..6514c31 --- /dev/null +++ b/js/src/features/home/Home.page.tsx @@ -0,0 +1,17 @@ +import { Anchor, Stack, Text, Title } from "@mantine/core"; +import { Link } from "react-router-dom"; + +/** Public landing page. */ +export default function HomePage() { + return ( + + PatChats + + Monthly one-on-one coffee chat pairings for the Patina Network. + + + Go to the app + + + ); +} diff --git a/js/src/features/sample/Sample.page.test.tsx b/js/src/features/sample/Sample.page.test.tsx new file mode 100644 index 0000000..1dbc932 --- /dev/null +++ b/js/src/features/sample/Sample.page.test.tsx @@ -0,0 +1,9 @@ +import SamplePage from "@/features/sample/Sample.page"; +import { renderWithProviders, screen } from "@/lib/test/render"; +import { expect, test } from "vitest"; + +test("renders the message returned by the API", async () => { + renderWithProviders(); + + expect(await screen.findByText("Hello from MSW")).toBeInTheDocument(); +}); diff --git a/js/src/features/sample/Sample.page.tsx b/js/src/features/sample/Sample.page.tsx new file mode 100644 index 0000000..dd46f84 --- /dev/null +++ b/js/src/features/sample/Sample.page.tsx @@ -0,0 +1,16 @@ +import { useSampleMessage } from "@/features/sample/api/useSampleMessage"; +import { Loader, Stack, Text, Title } from "@mantine/core"; + +/** Sample authenticated page — demonstrates the feature + data-layer pattern. */ +export default function SamplePage() { + const { data, isError, isPending } = useSampleMessage(); + + return ( + + Sample feature + {isPending && } + {isError && Failed to load message.} + {data && {data.message}} + + ); +} diff --git a/js/src/features/sample/SampleAdmin.page.tsx b/js/src/features/sample/SampleAdmin.page.tsx new file mode 100644 index 0000000..25c9770 --- /dev/null +++ b/js/src/features/sample/SampleAdmin.page.tsx @@ -0,0 +1,15 @@ +import { Stack, Text, Title } from "@mantine/core"; + +/** + * Sample admin-only page. Lives in the same feature as Sample.page to show that + * one domain can span permission levels — access is decided in the router, not + * the folder. + */ +export default function SampleAdminPage() { + return ( + + Sample admin page + Only admins can see this. + + ); +} diff --git a/js/src/features/sample/api/sample.mock.ts b/js/src/features/sample/api/sample.mock.ts new file mode 100644 index 0000000..9bb53db --- /dev/null +++ b/js/src/features/sample/api/sample.mock.ts @@ -0,0 +1,11 @@ +import { http, HttpResponse } from "msw"; + +/** + * MSW handlers for the sample domain's endpoints. Composed into the test server + * in `lib/test/server.ts`. Excluded from the production build via tsconfig. + */ +export const sampleHandlers = [ + http.get("/api/sample/message", () => + HttpResponse.json({ message: "Hello from MSW" }), + ), +]; diff --git a/js/src/features/sample/api/useSampleMessage.ts b/js/src/features/sample/api/useSampleMessage.ts new file mode 100644 index 0000000..7fc53d4 --- /dev/null +++ b/js/src/features/sample/api/useSampleMessage.ts @@ -0,0 +1,16 @@ +import { apiFetch } from "@/lib/api/client"; +import { useQuery } from "@tanstack/react-query"; + +export interface SampleMessage { + message: string; +} + +export const sampleMessageQueryKey = ["sample", "message"] as const; + +/** Domain-owned query hook: fetches the sample message from the backend. */ +export function useSampleMessage() { + return useQuery({ + queryKey: sampleMessageQueryKey, + queryFn: () => apiFetch("/sample/message"), + }); +} diff --git a/js/src/lib/api/client.ts b/js/src/lib/api/client.ts new file mode 100644 index 0000000..18ac83f --- /dev/null +++ b/js/src/lib/api/client.ts @@ -0,0 +1,33 @@ +/** + * Thin typed fetch wrapper over the backend API (proxied at `/api` in dev). + * + * This is the single place to handle JSON parsing, error mapping, and (later) + * auth headers. Once `schema.d.ts` is generated from the OpenAPI spec, the + * generics here can be tightened against `paths`. + */ + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + const response = await fetch(`/api${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }); + + if (!response.ok) { + throw new ApiError(response.status, `Request to ${path} failed`); + } + + return response.json() as Promise; +} diff --git a/js/src/lib/api/queryClient.ts b/js/src/lib/api/queryClient.ts new file mode 100644 index 0000000..1aa583d --- /dev/null +++ b/js/src/lib/api/queryClient.ts @@ -0,0 +1,7 @@ +import { QueryClient } from "@tanstack/react-query"; + +/** + * The single QueryClient for the app. Created at module scope so it is never + * re-created across renders. Tune defaults (retries, staleTime, etc.) here. + */ +export const queryClient = new QueryClient(); diff --git a/js/src/lib/api/schema.d.ts b/js/src/lib/api/schema.d.ts new file mode 100644 index 0000000..1a7737e --- /dev/null +++ b/js/src/lib/api/schema.d.ts @@ -0,0 +1,12 @@ +/** + * Types generated by `openapi-typescript` from the backend OpenAPI spec. + * + * This is a placeholder until codegen is wired up. Regenerate with, e.g.: + * pnpm openapi-typescript http://localhost:8080/v3/api-docs -o src/lib/api/schema.d.ts + * + * Once populated, `lib/api/client.ts` and domain `api/` hooks can be typed + * against `paths` / `components` from this file. + */ + +export type paths = Record; +export type components = Record; diff --git a/js/src/lib/api/useSession.ts b/js/src/lib/api/useSession.ts new file mode 100644 index 0000000..f48ec69 --- /dev/null +++ b/js/src/lib/api/useSession.ts @@ -0,0 +1,26 @@ +import { apiFetch } from "@/lib/api/client"; +import { useQuery } from "@tanstack/react-query"; + +/** + * The current authenticated user. + * + * PLACEHOLDER: this lives in `lib/api` so the route guards have a session source + * today. Once auth endpoints are wired, move this into a real `features/auth/api/` + * and import it from there. + */ +export interface Session { + id: string; + name: string; + isAdmin: boolean; +} + +export const sessionQueryKey = ["session"] as const; + +export function useSession() { + return useQuery({ + queryKey: sessionQueryKey, + queryFn: () => apiFetch("/session"), + retry: false, + staleTime: Infinity, + }); +} diff --git a/js/src/lib/queryProvider.tsx b/js/src/lib/queryProvider.tsx deleted file mode 100644 index 5031289..0000000 --- a/js/src/lib/queryProvider.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactNode } from "react"; - -/** - * The queryClient is outside of the React function so that it never gets re-created again on another render. - */ -const queryClient = new QueryClient(); - -export default function ReactQueryProvider({ - children, -}: { - children: ReactNode; -}) { - return ( - {children} - ); -} diff --git a/js/src/lib/router.tsx b/js/src/lib/router.tsx deleted file mode 100644 index cbf5a30..0000000 --- a/js/src/lib/router.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import RootPage from "@/app/Root.page"; -import AdminPage from "@/app/user/admin/Admin.page"; -import EmailsPage from "@/app/user/admin/emails/Emails.page"; -import IntakeFormPage from "@/app/user/intake/IntakeForm.page"; -import { createBrowserRouter } from "react-router-dom"; - -export const router = createBrowserRouter([ - { - path: "/", - element: , - errorElement: <>, // To-do - }, - { - path: "/admin", - element: , - errorElement: <>, // To-do - }, - { - path: "/admin/emails", - element: , - errorElement: <>, // To-do - }, - { - path: "/intake-form", - element: , - errorElement: <>, // To-do - }, -]); diff --git a/js/src/lib/test/defaults.tsx b/js/src/lib/test/defaults.tsx new file mode 100644 index 0000000..2302720 --- /dev/null +++ b/js/src/lib/test/defaults.tsx @@ -0,0 +1,27 @@ +import { server } from "@/lib/test/server"; +import "@testing-library/jest-dom/vitest"; +import { afterAll, afterEach, beforeAll, vi } from "vitest"; + +// --- MSW lifecycle: mock the network for every test --- +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +// --- Browser APIs jsdom lacks but Mantine needs --- +vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), +})); + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal("ResizeObserver", ResizeObserverMock); diff --git a/js/src/lib/test/render.tsx b/js/src/lib/test/render.tsx new file mode 100644 index 0000000..946079d --- /dev/null +++ b/js/src/lib/test/render.tsx @@ -0,0 +1,37 @@ +import { themeOverride } from "@/app/providers/theme"; +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, RenderOptions } from "@testing-library/react"; +import { ReactElement, ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; + +/** + * A fresh QueryClient per render so tests never share cache. Retries are off so + * failed requests surface immediately instead of hanging the test. + */ +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + }; +} + +/** Render a component wrapped in the app's providers (Query, Mantine, Router). */ +export function renderWithProviders( + ui: ReactElement, + options?: Omit, +) { + return render(ui, { wrapper: createWrapper(), ...options }); +} + +// eslint-disable-next-line react-refresh/only-export-components +export * from "@testing-library/react"; diff --git a/js/src/lib/test/server.ts b/js/src/lib/test/server.ts new file mode 100644 index 0000000..3c2f213 --- /dev/null +++ b/js/src/lib/test/server.ts @@ -0,0 +1,8 @@ +import { sampleHandlers } from "@/features/sample/api/sample.mock"; +import { setupServer } from "msw/node"; + +/** + * MSW server for tests. Each domain owns its request handlers in + * `features//api/.mock.ts`; compose them here. + */ +export const server = setupServer(...sampleHandlers); diff --git a/js/src/main.tsx b/js/src/main.tsx index f11c533..fdfd952 100644 --- a/js/src/main.tsx +++ b/js/src/main.tsx @@ -1,10 +1,10 @@ // THIS MUST BE FIRST. NEVER MOVE THIS. import "@/patches"; import "@mantine/core/styles.css"; -import ReactQueryProvider from "@/lib/queryProvider"; -import { router } from "@/lib/router"; +import QueryProvider from "@/app/providers/QueryProvider"; +import { themeOverride } from "@/app/providers/theme"; +import { router } from "@/app/router/router"; import "@mantine/notifications/styles.css"; -import { themeOverride } from "@/lib/theme"; import { MantineProvider } from "@mantine/core"; import "@mantine/dates/styles.css"; import { Notifications } from "@mantine/notifications"; @@ -21,12 +21,12 @@ import { RouterProvider } from "react-router"; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion createRoot(document.getElementById("root")!).render( - + - + , ); diff --git a/js/tsconfig.app.json b/js/tsconfig.app.json index 8f23a1f..e6ad68d 100644 --- a/js/tsconfig.app.json +++ b/js/tsconfig.app.json @@ -28,5 +28,10 @@ } }, "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/lib/test"] + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.mock.ts", + "src/lib/test" + ] } diff --git a/js/tsconfig.test.json b/js/tsconfig.test.json index 201260f..9896b34 100644 --- a/js/tsconfig.test.json +++ b/js/tsconfig.test.json @@ -31,6 +31,7 @@ "include": [ "src/**/*.test.ts", "src/**/*.test.tsx", + "src/**/*.mock.ts", "src/lib/test", "src/vite-env.d.ts", "src/main.tsx"