diff --git a/infra/keycloak/themes/roost/login/resources/css/roost.css b/infra/keycloak/themes/roost/login/resources/css/roost.css index eb480130..254d08cf 100644 --- a/infra/keycloak/themes/roost/login/resources/css/roost.css +++ b/infra/keycloak/themes/roost/login/resources/css/roost.css @@ -61,6 +61,8 @@ --roost-border: oklch(0.92 0.004 286.32); --roost-input-bg: oklch(1 0 0); --roost-destructive: oklch(0.577 0.245 27.325); + --roost-warning: oklch(0.72 0.16 78); + --roost-success: var(--roost-primary); --roost-radius: 0; /* Ambient gradient spots, straight from the web app. */ @@ -78,6 +80,9 @@ --roost-muted-foreground: oklch(0.705 0.015 286.067); --roost-border: oklch(1 0 0 / 12%); --roost-input-bg: oklch(0.18 0.005 285.8); + --roost-destructive: oklch(0.704 0.191 22.216); + --roost-warning: oklch(0.82 0.14 82); + --roost-success: var(--roost-primary); --roost-glow-1: oklch(0.55 0.12 128 / 0.22); --roost-glow-2: oklch(0.6 0.06 86 / 0.16); } @@ -270,7 +275,7 @@ textarea.pf-c-form-control { height: 2.75rem; border-radius: var(--roost-radius); border: 1px solid var(--roost-border); - background: var(--roost-input-bg); + background-color: var(--roost-input-bg); font-family: 'Poppins', sans-serif; font-size: 0.9375rem; color: var(--roost-foreground); @@ -293,6 +298,18 @@ input[type='email']:focus { box-shadow: 0 0 0 3px color-mix(in oklch, var(--roost-primary) 30%, transparent); } +/* Keycloak/PatternFly adds validation icons as background images. The Roost + theme uses plain border + helper text errors, so remove those image + backgrounds to avoid tiled icons inside invalid fields. */ +.pf-c-form-control[aria-invalid='true'], +.pf-v5-c-form-control[aria-invalid='true'], +input[aria-invalid='true'], +textarea[aria-invalid='true'] { + border-color: var(--roost-destructive); + background-color: var(--roost-input-bg); + background-image: none; +} + /* ---- Buttons (match the app Button: uppercase, tracked out, semibold) ---- */ .pf-c-button, input[type='submit'], @@ -399,23 +416,124 @@ a:hover { /* ---- Alerts / errors ---- */ .pf-c-alert, +.pf-v5-c-alert, .alert { + --roost-alert-accent: var(--roost-muted-foreground); + --roost-alert-background: color-mix(in oklch, var(--roost-muted-foreground) 8%, transparent); + --roost-alert-border: var(--roost-border); + --pf-c-alert--BoxShadow: none; + --pf-c-alert--BackgroundColor: var(--roost-alert-background); + --pf-c-alert--BorderTopWidth: 0; + --pf-c-alert--BorderTopColor: transparent; + --pf-c-alert__icon--Color: var(--roost-alert-accent); + --pf-c-alert__title--Color: var(--roost-alert-accent); + --pf-c-alert--m-inline--BackgroundColor: var(--roost-alert-background); + --pf-c-alert--m-inline--m-success--BackgroundColor: var(--roost-alert-background); + --pf-c-alert--m-inline--m-danger--BackgroundColor: var(--roost-alert-background); + --pf-c-alert--m-inline--m-warning--BackgroundColor: var(--roost-alert-background); + --pf-c-alert--m-inline--m-info--BackgroundColor: var(--roost-alert-background); + --pf-v5-c-alert--BoxShadow: none; + --pf-v5-c-alert--BackgroundColor: var(--roost-alert-background); + --pf-v5-c-alert--BorderTopWidth: 0; + --pf-v5-c-alert--BorderTopColor: transparent; + --pf-v5-c-alert__icon--Color: var(--roost-alert-accent); + --pf-v5-c-alert__title--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-inline--m-custom--BackgroundColor: var(--roost-alert-background); + --pf-v5-c-alert--m-inline--m-success--BackgroundColor: var(--roost-alert-background); + --pf-v5-c-alert--m-inline--m-danger--BackgroundColor: var(--roost-alert-background); + --pf-v5-c-alert--m-inline--m-warning--BackgroundColor: var(--roost-alert-background); + --pf-v5-c-alert--m-inline--m-info--BackgroundColor: var(--roost-alert-background); border-radius: var(--roost-radius); + border: 1px solid var(--roost-alert-border); + border-left: 3px solid var(--roost-alert-accent); + border-top: 0; + border-block-start: 0; + box-shadow: none; + background: var(--roost-alert-background); font-family: 'Poppins', sans-serif; font-size: 0.875rem; + color: var(--roost-foreground); margin-bottom: 1.25rem; } .pf-c-alert.pf-m-danger, +.pf-v5-c-alert.pf-m-danger, .alert-error { - border-radius: var(--roost-radius); - border-left: 3px solid var(--roost-destructive); - background: color-mix(in oklch, var(--roost-destructive) 10%, transparent); + --roost-alert-accent: var(--roost-destructive); + --roost-alert-background: color-mix(in oklch, var(--roost-destructive) 10%, transparent); + --roost-alert-border: color-mix(in oklch, var(--roost-destructive) 30%, transparent); + --pf-c-alert--m-danger--BorderTopColor: var(--roost-alert-accent); + --pf-c-alert--m-danger__icon--Color: var(--roost-alert-accent); + --pf-c-alert--m-danger__title--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-danger--BorderTopColor: var(--roost-alert-accent); + --pf-v5-c-alert--m-danger__icon--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-danger__title--Color: var(--roost-alert-accent); +} + +.pf-c-alert.pf-m-warning, +.pf-v5-c-alert.pf-m-warning, +.alert-warning { + --roost-alert-accent: var(--roost-warning); + --roost-alert-background: color-mix(in oklch, var(--roost-warning) 10%, transparent); + --roost-alert-border: color-mix(in oklch, var(--roost-warning) 32%, transparent); + --pf-c-alert--m-warning--BorderTopColor: var(--roost-alert-accent); + --pf-c-alert--m-warning__icon--Color: var(--roost-alert-accent); + --pf-c-alert--m-warning__title--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-warning--BorderTopColor: var(--roost-alert-accent); + --pf-v5-c-alert--m-warning__icon--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-warning__title--Color: var(--roost-alert-accent); +} + +.pf-c-alert.pf-m-success, +.pf-v5-c-alert.pf-m-success, +.alert-success { + --roost-alert-accent: var(--roost-success); + --roost-alert-background: color-mix(in oklch, var(--roost-success) 10%, transparent); + --roost-alert-border: color-mix(in oklch, var(--roost-success) 30%, transparent); + --pf-c-alert--m-success--BorderTopColor: var(--roost-alert-accent); + --pf-c-alert--m-success__icon--Color: var(--roost-alert-accent); + --pf-c-alert--m-success__title--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-success--BorderTopColor: var(--roost-alert-accent); + --pf-v5-c-alert--m-success__icon--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-success__title--Color: var(--roost-alert-accent); +} + +.pf-c-alert.pf-m-info, +.pf-v5-c-alert.pf-m-info, +.alert-info { + --roost-alert-accent: var(--roost-muted-foreground); + --roost-alert-background: color-mix(in oklch, var(--roost-muted-foreground) 8%, transparent); + --roost-alert-border: var(--roost-border); + --pf-c-alert--m-info--BorderTopColor: var(--roost-alert-border); + --pf-c-alert--m-info__icon--Color: var(--roost-alert-accent); + --pf-c-alert--m-info__title--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-info--BorderTopColor: var(--roost-alert-border); + --pf-v5-c-alert--m-info__icon--Color: var(--roost-alert-accent); + --pf-v5-c-alert--m-info__title--Color: var(--roost-alert-accent); +} + +.pf-c-alert__icon, +.pf-v5-c-alert__icon, +.pf-c-alert__title, +.pf-v5-c-alert__title, +.alert .kc-feedback-text { + color: var(--roost-alert-accent); +} + +.pf-c-alert__title, +.pf-v5-c-alert__title { + font-family: 'Poppins', sans-serif; } .kc-feedback-text, -.pf-c-form__helper-text.pf-m-error { +.pf-c-form__helper-text.pf-m-error, +.pf-v5-c-form__helper-text.pf-m-error { font-family: 'Poppins', sans-serif; +} + +.kc-feedback-text:not(.pf-c-alert__title):not(.pf-v5-c-alert__title), +.pf-c-form__helper-text.pf-m-error, +.pf-v5-c-form__helper-text.pf-m-error { color: var(--roost-destructive); } diff --git a/web-client/README.md b/web-client/README.md index d154f448..c500fd2c 100644 --- a/web-client/README.md +++ b/web-client/README.md @@ -2,106 +2,281 @@ React + TypeScript single-page application for the team-devoops club management platform. +It is the only user-facing surface of the repo. It talks to the Spring/Python services +exclusively over `/api/v1/*` (never to a service host directly), and its request/response +types are generated from the same `api/openapi.yaml` those services are generated from — +so the contract is shared, not re-described here. See the [root README](../README.md) for +the wider architecture. + ## Stack | | | |---|---| -| Framework | React 19, TypeScript 5 | -| Build tool | Vite 6 | -| Package manager | `pnpm` | -| Routing | React Router v7 | -| HTTP client | Axios | +| Framework | React 19, TypeScript 6 | +| Build tool | Vite 8 (Rolldown) | +| Package manager | pnpm 9.15.9 (pinned via `packageManager`) | +| Node | 24 (`engines`: `>=22 <25`, `.nvmrc`: 24) | +| Routing | React Router 7 (`createBrowserRouter`) | +| Server state | TanStack Query 5 | +| Client state | Zustand 5 | +| HTTP | Axios | +| Forms | React Hook Form + Zod 4 | | Auth | keycloak-js 26 (PKCE S256) | -| Tests | Vitest + jsdom | -| Linting | ESLint (flat config) | - -## Design System - -The client uses: - -- `shadcn` primitives for reusable UI building blocks -- Tailwind CSS v4 with CSS variable theming -- the `Sera` style preset configured in `components.json` - -The global theme lives in `src/index.css`. Light and dark mode are driven by semantic tokens such as `--background`, `--primary`, and `--card`. +| Styling | Tailwind CSS 4 + shadcn/ui (`radix-sera` style) | +| Unit tests | Vitest + jsdom | +| E2E | Playwright (Chromium) | +| Linting | ESLint 10 (flat config) | ## Prerequisites -- **Node.js 20+** and **pnpm** (`npm install -g pnpm`) -- **Keycloak running locally** on port 8081 — start it (and the rest of the backend, see "Mock data" below) with `docker compose up -d --build` from `infra/` (see root README) +- **Node 24** (`nvm use` reads `.nvmrc`) and **pnpm 9** (`corepack enable` picks up the pinned version) +- **The backend stack**, for anything other than E2E: `docker compose up -d --build` from `infra/`. + The app has no offline/mock mode — see [Working without the backend](#working-without-the-backend). +- **Chromium for Playwright**, only if you run E2E: `pnpm exec playwright install chromium` -## Local development +## Getting started ```bash pnpm install -pnpm dev # Vite dev server at http://localhost:3000 +pnpm dev # Vite dev server on http://localhost:3000 (opens a browser) ``` -The app requires Keycloak to be reachable at `VITE_KEYCLOAK_URL` (default `http://localhost:8081`) before it renders. On first load it redirects to the Keycloak login page. +On load the app runs `keycloak.init({ onLoad: 'check-sso' })` and redirects to the Keycloak +login page if there is no session. It renders nothing until auth resolves, so Keycloak must be +reachable at `VITE_KEYCLOAK_URL` (default `http://localhost:8081/auth`). -To override the Keycloak URL: +Local test users come from the realm import in `infra/`: -```bash -VITE_KEYCLOAK_URL=http://localhost:8081 pnpm dev -``` +| User | Password | Realm roles | +|---|---|---| +| `admin` | `admin123` | `admin`, `member` | +| `user` | `user123` | `member` | -### Mock data +### Working without the backend -By default `pnpm dev` calls the real backend services (proxied to `http://localhost` — see `vite.config.ts`), same as every deployed environment (docker-compose, VM, Kubernetes all already run live — none of them ever set `VITE_USE_MOCKS`). To work on the UI without the backend running, copy `.env.development.example` to `.env.development` and set `VITE_USE_MOCKS=true`; every feature then serves fixtures from `src/mocks/fixtures/` instead, scoped to a demo persona selected via `VITE_MOCK_PERSONA` (`member | coach | director | admin`). `.env.development` is gitignored, so this choice is per-developer and never committed. +**`pnpm dev` cannot fully work on its own, and there is no mock mode.** Two things get in the way: -**Known limitation:** every backend route is also gated by Traefik's `forward-auth` middleware, which needs its own session cookie — established by a full-page login through Traefik, then sent automatically on same-origin requests. `pnpm dev` serves the SPA itself from Vite (port 3000), so that cookie never gets set, and live-mode API calls will fail. This doesn't affect the actual deployment: browsing the docker-compose stack directly at `http://localhost/` (rather than `pnpm dev`) goes through Traefik end-to-end and works correctly. For local UI iteration against real look-and-feel without touching this, use `VITE_USE_MOCKS=true`. +1. Every `/api/v1/*` route is behind Traefik's `forward-auth` middleware, which needs a session + cookie established by a full-page login *through Traefik*. Vite serves the SPA itself on port + 3000, so that cookie is never set and live API calls fail. +2. Vite's dev proxy forwards `/api` to `http://localhost` (see `vite.config.ts`), which only + exists when the compose stack is up. -## Authentication +Two ways to actually see the app: -Authentication is handled by [`src/lib/keycloak.ts`](src/lib/keycloak.ts): +- **Full stack:** run `docker compose up -d --build` from `infra/` and browse + **`http://localhost/`** (the Traefik-served build) rather than `pnpm dev`. This is the path + that works end-to-end. +- **Offline UI work:** run the Playwright E2E suite, which serves the whole API in memory and + stubs Keycloak. `pnpm e2e:ui` gives you a live, clickable app with no services running. -- The Keycloak singleton connects to realm `devops`, client `devops-client` (public, PKCE S256). -- `main.tsx` initialises Keycloak with `onLoad: 'login-required'` — the app only mounts after the user is authenticated. -- `createApiClient(baseURL)` returns an Axios instance whose request interceptor refreshes the token (if it expires within 30 s) and injects `Authorization: Bearer ` on every outgoing request. +## Environment variables -Local test users (auto-imported from realm config): +The app reads exactly **one** `VITE_*` variable. Vite inlines `VITE_*` at build time, so in +Docker they are build `ARG`s, not runtime env. -| User | Password | Roles | -|---|---|---| -| `admin` | `admin123` | `admin`, `member` | -| `user` | `user123` | `member` | +| Name | Purpose | Required | Example | +|---|---|---|---| +| `VITE_KEYCLOAK_URL` | Base URL of the Keycloak server. Realm (`devops`) and client (`devops-client`) are hard-coded in `src/lib/keycloak.ts`. | No — defaults to `http://localhost:8081/auth` | `https://your-host/auth` | -## Build & deploy +Local overrides go in `.env.development` (gitignored; copy `.env.development.example`). + +`Dockerfile` also declares `VITE_MEMBERS_URL`, `VITE_EVENTS_URL`, `VITE_FINANCE_URL` and +`VITE_LETTERS_URL` as build args. **No code reads them** — they are leftovers from before +path-based proxy routing, when each service had its own host. CD only passes `VITE_KEYCLOAK_URL` +(see the `build_args` matrix entry in `.github/workflows/cd.yml`). + +## Scripts + +| Command | What it does | +|---|---| +| `pnpm dev` | Vite dev server on :3000, proxying `/api` → `http://localhost` | +| `pnpm build` | `tsc -b` then `vite build` → `dist/` | +| `pnpm preview` | Serve the built `dist/` | +| `pnpm typecheck` | `tsc -b` across the app, node and e2e tsconfig projects; no emit | +| `pnpm lint` / `pnpm lint:fix` | ESLint (flat config) | +| `pnpm test` | Vitest, single run | +| `pnpm test:watch` | Vitest in watch mode | +| `pnpm test:coverage` | Vitest with v8 coverage (this is what CI runs) | +| `pnpm e2e` | `pnpm build`, then Playwright against a `vite preview` server | +| `pnpm e2e:ui` | Playwright UI mode | +| `pnpm verify` | lint + typecheck + test + build — run this before pushing | + +CI (`.github/workflows/ci.yml`) runs typecheck, lint, `test:coverage` and build. **It does not +run E2E** — that is a local gate. + +## Project structure -```bash -pnpm build # outputs to dist/ ``` +src/ +├── main.tsx # Entry: QueryClient config, ThemeProvider, mounts AuthenticatedApp +├── AuthenticatedApp.tsx # Runs keycloak.init(); renders spinner → error card → the app +├── App.tsx # RouterProvider +├── api.ts # ⚠ GENERATED from api/openapi.yaml — do not edit (eslint-ignored) +├── types.ts # Hand-written aliases/unions over the generated schemas +├── app/ # App-level shell, not a feature +│ ├── layout/ # AppShell: sidebar, user menu, theme switch, toaster +│ ├── router/ # routes.tsx + RouteRoleGuard (role-gated routes) +│ ├── pages/ # Dashboard, 404, route-error page +│ ├── theme/ # ThemeProvider/useTheme (light | dark | system) +│ ├── navPolicy.ts # SINGLE source of which roles may use which route +│ └── ErrorBoundary.tsx +├── features/ # One folder per domain; see "Feature anatomy" below +│ ├── auth/ feedback/ helper/ letters/ members/ +│ ├── organization/ payments/ profile/ sport-events/ +├── components/ui/ # shadcn primitives + local ones (data-table, stat-card, …) +├── lib/ # Cross-cutting: keycloak/axios, query keys & cache, forms, formatting +├── hooks/ # Shared React hooks (use-mobile) +├── store/ # Zustand store for app-wide UI state +├── testing/ # Test-only fixtures, personas, role-scoping helpers +└── index.css # Tailwind 4 theme: CSS variables, fonts, typography scale + +e2e/ # Playwright specs + in-memory server (see e2e/README.md) +``` + +### Feature anatomy + +Every feature under `src/features/` follows the same shape, and an `index.ts` barrel is the +only thing other modules should import from: + +| Folder | Holds | +|---|---| +| `api/client.ts` | The axios instance and its `/api/v1/` base URL | +| `api/queries.ts` | TanStack Query hooks (`useMembers`, `useCreateMember`, …) | +| `model/` | View-models and form/editor logic — the testable part, no JSX | +| `components/` | Feature-local dialogs and widgets | +| `pages/` | The routed page component | +| `types/` | Re-exports of the generated types this feature uses | + +Base URLs per feature (all proxied through Traefik): `members` → `/api/v1/members`, +`organization` → `/api/v1/organization`, `sport-events` → `/api/v1/events`, +`payments` → `/api/v1/finance`, `feedback` → `/api/v1/feedback`, `letters` → `/api/v1/letters`, +`helper` → `/api/v1/helper`. The dashboard is served by the members service +(`/api/v1/members/dashboard`). -The `Dockerfile` accepts a `VITE_KEYCLOAK_URL` build argument so the production Keycloak URL is baked in at image build time: +## How it fits together + +### Auth and identity + +`src/lib/keycloak.ts` owns the Keycloak singleton (realm `devops`, client `devops-client`, +public, PKCE S256) and `createApiClient(baseURL)`, the axios factory every feature uses: + +- A request interceptor refreshes the token when it expires within 30s, sharing one in-flight + refresh across concurrent requests, and sets `Authorization: Bearer `. +- A response interceptor calls `keycloak.login()` on any 401. +- Default request timeout is 15s. + +**Identity comes only from the Keycloak token.** `getCurrentUser()` +(`src/features/auth/currentUser.ts`) reads the parsed token's `member_roles` claim and collapses +it to a single `Role` via `highestRole()` — there is no separate "who am I" request. `useAuth()` +re-reads that snapshot on every token refresh, so a profile edit that mints a new token updates +the UI. + +### Roles and routing + +`src/app/navPolicy.ts` is the single source of truth for role access: `NAV_ITEMS` drives both the +sidebar and `ROUTE_ROLES`, which `RouteRoleGuard` enforces in `routes.tsx`. Add a route there and +both stay in sync. Roles are `member | trainer | director | admin`; a member has exactly one. + +Rows are additionally *scoped* per role (a trainer sees only their teams' members, etc.). The +server does this for real; `src/testing/scope.ts` mirrors it for tests and the E2E server. + +### Server state + +TanStack Query is the cache; there is no Redux-style store for server data. Two files carry the +non-obvious rules and are worth reading before adding a mutation: + +- **`src/lib/query-keys.ts`** — every query key *and* the cross-resource dependency map. Keys live + here rather than beside their hooks because mutating one resource invalidates others (a member + rename changes every roster; a transaction changes a balance), and keeping keys next to hooks + would make feature modules import each other in a cycle. +- **`src/lib/query-cache.ts`** — `settleMutation`, the single entry point mutations call in + `onSuccess`. The server is authoritative: lists are never re-sorted or spliced locally (only + replace-by-id and remove-by-id), and everything a mutation touches indirectly is refetched. + +Zustand (`src/store/ui.ts`, plus a `*UiStore.ts` per feature) holds only ephemeral UI state — +which dialog is open, which row is selected. Never server data. + +### Theming + +Tailwind 4 with CSS-variable tokens in `src/index.css`; shadcn `radix-sera` style, zinc base, +`--radius: 0`. `ThemeProvider` cycles `light → dark → system`, persists to `localStorage` +(`ui-theme`), toggles `.dark` on ``, and follows the OS in `system` mode. Fonts: Poppins +(body), Bebas Neue (`font-display`). Prefer semantic tokens (`bg-card`, `text-text-tertiary`, +`text-h2`) over raw colors. + +### Generated types + +`src/api.ts` is generated by `openapi-typescript` from `api/openapi.yaml` via +`./api/scripts/gen-all.sh` (also wired into the repo's pre-commit hook). Do not edit it; it is in +ESLint's ignore list. `src/types.ts` is the hand-written layer on top — aliases, the `EventListItem` +augmentation, and the dashboard union. + +## Testing + +### Unit / component (Vitest + jsdom) ```bash -docker build \ - --build-arg VITE_KEYCLOAK_URL=https://your-host/auth \ - -t web-client . +pnpm test # all +pnpm test src/features/members # one folder +pnpm test -t "creates a member" # by test name +pnpm test:watch ``` -In the CD pipeline this is set automatically to the cluster hostname via the `build_args` matrix entry in `.github/workflows/cd.yml`. +Tests are **colocated** with the code (`memberEditor.test.ts` next to `memberEditor.ts`); Vitest +picks up `src/**/*.{test,spec}.{ts,tsx}`. A handful of app-level tests still sit in +`src/__tests__/`. Setup lives in `src/setupTests.ts`, fixtures in `src/testing/fixtures/`. -## Scripts - -- `pnpm dev` starts the Vite dev server -- `pnpm lint` runs ESLint -- `pnpm typecheck` runs TypeScript in build mode without emitting files -- `pnpm test` runs Vitest -- `pnpm build` creates the production bundle -- `pnpm verify` runs lint, typecheck, tests, and build +These cover what E2E deliberately doesn't: the per-role sweep (nav visibility, route-guard deny, +scoping), dialog validation branches, and view-model logic. -## Running tests +### E2E (Playwright) ```bash -pnpm test # run once -pnpm test --watch # watch mode +pnpm exec playwright install chromium # one-time +pnpm e2e # build + headless run +pnpm e2e:ui # interactive +pnpm exec playwright test e2e/members.spec.ts # one spec (build first) +pnpm exec playwright test e2e/members.spec.ts -g "creates a member" ``` -Tests live in `src/__tests__/` and use Vitest with jsdom. +**No services and no Keycloak are needed.** The suite drives the real production bundle and stubs +everything below it, per browser context, before the app loads: + +- **API** — `e2e/support/api.ts` intercepts `**/api/v1/**` with `context.route` and answers from + an in-memory server (`e2e/support/server/*`, one module per resource). Its state is a deep clone + of the same fixtures the unit tests use, reset before every test, so specs are order-independent. +- **Keycloak** — `e2e/support/auth.ts` intercepts the three requests keycloak-js makes (the + third-party-cookie probe, the silent-check-sso authorization request, the code-for-token + exchange) and mints an **unsigned** JWT echoing the request's `nonce`. keycloak-js doesn't verify + signatures client-side, so this is enough for `init()` to resolve as authenticated. +- **Identity** — since identity is read from the token, the minted token *is* the identity. It is + the seeded **admin** persona (`src/testing/personas.ts`), and the in-memory server answers as that + same admin. The suite therefore runs as **one identity, admin — there is no persona switching and + no env var that selects one.** Role variation is covered by unit tests instead. + +`pnpm e2e` builds first and serves `dist/` via `vite preview` on :5199 (Vite's per-worker transform +contention was starving the auth handshake); workers are capped at 4. `e2e/README.md` has the detail. + +## Conventions + +- Import from `src` with the `@/` alias. +- Import a feature through its barrel (`@/features/members`), not deep paths. +- New shared primitives go in `src/components/ui`. Look components up with the **`shadcn` MCP + server** before hand-writing them (see the root `CLAUDE.md`). +- Prefer semantic theme tokens over hard-coded colors. +- Run `pnpm verify` before pushing. -## Notes +## Build & deploy -- Use the `@/` alias for imports from `src` -- Add new reusable primitives under `src/components/ui` -- Prefer semantic theme tokens over hard-coded colors when extending the UI +```bash +pnpm build # → dist/ +``` + +`Dockerfile` is a two-stage build (Node 24 builder → `nginx-unprivileged` on port 8080). Keycloak's +silent-check-sso iframe needs `X-Frame-Options` omitted for `/silent-check-sso.html` — `nginx.conf` +special-cases exactly that, so don't "tidy" it into the global header block. + +```bash +docker build --build-arg VITE_KEYCLOAK_URL=https://your-host/auth -t web-client . +``` diff --git a/web-client/e2e/app-shell.spec.ts b/web-client/e2e/app-shell.spec.ts index 98f83073..424f83e0 100644 --- a/web-client/e2e/app-shell.spec.ts +++ b/web-client/e2e/app-shell.spec.ts @@ -9,7 +9,6 @@ test('sidebar shows exactly the nav items the admin role may use', async ({ page await expect(page.getByRole('link', { name: item.label, exact: true })).toBeVisible() } - // Nav renders from navPolicy.ts only — no extra destinations beyond the allowed set. await expect(page.locator('a[data-sidebar="menu-button"]')).toHaveCount(allowed.length) }) @@ -39,7 +38,6 @@ test('theme switch flips the dark class and persists to localStorage', async ({ .poll(() => page.evaluate(() => localStorage.getItem('ui-theme'))) .toBe('dark') - // Survives a full reload (ThemeProvider re-reads localStorage). await gotoApp(page) await expect(page.locator('html')).toHaveClass(/dark/) diff --git a/web-client/e2e/boot.spec.ts b/web-client/e2e/boot.spec.ts index 25589d08..c82aa769 100644 --- a/web-client/e2e/boot.spec.ts +++ b/web-client/e2e/boot.spec.ts @@ -4,7 +4,6 @@ test('boots past the auth spinner into the app shell', async ({ page }) => { await gotoApp(page) await expect(page.getByRole('heading', { name: 'Roost' })).toBeVisible() - // Neither the auth error card nor the spinner may remain. await expect(page.getByText('Sign-in error')).toHaveCount(0) await expect(page.getByText('Cannot reach authentication server')).toHaveCount(0) }) diff --git a/web-client/e2e/cache-consistency.spec.ts b/web-client/e2e/cache-consistency.spec.ts new file mode 100644 index 00000000..cb0ce43f --- /dev/null +++ b/web-client/e2e/cache-consistency.spec.ts @@ -0,0 +1,201 @@ +import type { Page } from '@playwright/test' + +import { + feedbackSummaryFixtures, + memberFixtures, + NAV_ITEMS, + sportFixtures, + teamFixtures, +} from './support/data' +import { expect, gotoApp, test, toastRegion } from './support/fixtures' + +// Do not reload between pages: rebuilding the query cache would hide these regressions. + +function fixtureOrThrow(value: T | undefined, description: string): T { + if (value === undefined) throw new Error(`Missing E2E fixture: ${description}`) + return value +} + +// Derive relationships from fixtures so changes fail explicitly instead of using stale names. +const defaultSport = fixtureOrThrow(sportFixtures[0], 'at least one sport') +const rosterTeam = fixtureOrThrow( + teamFixtures.find( + (team) => + team.sport.id === defaultSport.id && + team.trainees.some((trainee) => + feedbackSummaryFixtures.some((feedback) => feedback.member.id === trainee.id), + ), + ), + 'a team in the default sport with a trainee who has feedback', +) +const rosterMemberRef = fixtureOrThrow( + rosterTeam.trainees.find((trainee) => + feedbackSummaryFixtures.some((feedback) => feedback.member.id === trainee.id), + ), + 'a roster member referenced by feedback', +) +const rosterMember = fixtureOrThrow( + memberFixtures.find((member) => member.id === rosterMemberRef.id), + 'the full member record for the roster member', +) +const rosterMemberName = `${rosterMember.first_name} ${rosterMember.last_name}` + +const createdMember = { + firstName: 'Cache', + lastName: 'Consistency', + email: 'cache.consistency@club.de', + password: 'changeme-e2e', +} +const createdMemberName = `${createdMember.firstName} ${createdMember.lastName}` + +// These scenarios load multiple large fixture tables around a mutation. Their observed runtime is +// longer than the suite's normal single-page CRUD budget, so only this file gets a larger timeout. +test.describe.configure({ timeout: 60_000 }) + +async function navigateTo(page: Page, path: string) { + const navItem = fixtureOrThrow( + NAV_ITEMS.find((item) => item.to === path), + `navigation item for ${path}`, + ) + + await page.getByRole('link', { name: navItem.label, exact: true }).click() + await expect(page).toHaveURL(new RegExp(`${path.replace('/', '\\/')}$`)) +} + +// The organization page expands its first fixture sport by default. The fixture selection above +// deliberately chooses a team in that sport, avoiding timing-sensitive expand/collapse probing. +async function openTeamRoster(page: Page, team: string) { + const teamRow = page.getByRole('button', { name: `${team} Coach`, exact: false }) + await expect(teamRow).toBeVisible() + await teamRow.click() + return page.getByRole('dialog') +} + +async function deleteMember(page: Page, name: string) { + await page.getByRole('button', { name: `Delete ${name}` }).click() + const confirm = page.getByRole('alertdialog', { name: 'Delete member' }) + await confirm.getByRole('button', { name: 'Delete', exact: true }).click() + await expect(toastRegion(page)).toContainText('Member deleted.') +} + +test('a deleted member disappears from the team roster that embedded them', async ({ page }) => { + await gotoApp(page, '/organization') + + const roster = await openTeamRoster(page, rosterTeam.name) + await expect(roster.getByText(rosterMemberName, { exact: true })).toBeVisible() + const membersHeadingBefore = await roster.getByText(/^Members \(\d+\)$/).textContent() + await page.keyboard.press('Escape') + + await navigateTo(page, '/members') + await deleteMember(page, rosterMemberName) + + await navigateTo(page, '/organization') + const rosterAfter = await openTeamRoster(page, rosterTeam.name) + await expect(rosterAfter.getByText(rosterMemberName, { exact: true })).toHaveCount(0) + + const before = Number(/\((\d+)\)/.exec(membersHeadingBefore ?? '')?.[1]) + await expect(rosterAfter.getByText(`Members (${before - 1})`, { exact: true })).toBeVisible() +}) + +test('a deleted member disappears from the feedback list that referenced them', async ({ page }) => { + await gotoApp(page, '/feedback') + await expect( + page.getByRole('cell', { name: rosterMemberName, exact: true }).first(), + ).toBeVisible() + + await navigateTo(page, '/members') + await deleteMember(page, rosterMemberName) + + await navigateTo(page, '/feedback') + await expect(page.getByRole('cell', { name: rosterMemberName, exact: true })).toHaveCount(0) +}) + +test('renaming a member updates the name shown in their team roster and feedback', async ({ + page, +}) => { + await gotoApp(page, '/organization') + + const rosterBefore = await openTeamRoster(page, rosterTeam.name) + await expect(rosterBefore.getByText(rosterMemberName, { exact: true })).toBeVisible() + await page.keyboard.press('Escape') + + await navigateTo(page, '/feedback') + await expect( + page.getByRole('cell', { name: rosterMemberName, exact: true }).first(), + ).toBeVisible() + + await navigateTo(page, '/members') + + await page.getByRole('button', { name: `Edit ${rosterMemberName}` }).click() + const dialog = page.getByRole('dialog', { name: 'Edit Member' }) + const renamedFirstName = `Renamed-${rosterMember.first_name}` + const renamedMemberName = `${renamedFirstName} ${rosterMember.last_name}` + await dialog.getByLabel('First name').fill(renamedFirstName) + await dialog.getByRole('button', { name: 'Next' }).click() + await dialog.getByRole('button', { name: 'Next' }).click() + await dialog.getByRole('button', { name: 'Save Member' }).click() + await expect(toastRegion(page)).toContainText('Member updated.') + + await navigateTo(page, '/organization') + const roster = await openTeamRoster(page, rosterTeam.name) + await expect(roster.getByText(renamedMemberName, { exact: true })).toBeVisible() + await expect(roster.getByText(rosterMemberName, { exact: true })).toHaveCount(0) + + await page.keyboard.press('Escape') + await navigateTo(page, '/feedback') + await expect( + page.getByRole('cell', { name: renamedMemberName, exact: true }).first(), + ).toBeVisible() + await expect(page.getByRole('cell', { name: rosterMemberName, exact: true })).toHaveCount(0) +}) + +test('a created member is immediately addressable as a feedback recipient', async ({ page }) => { + await gotoApp(page, '/members') + + await page.getByRole('button', { name: 'New member' }).click() + const dialog = page.getByRole('dialog', { name: 'New Member' }) + await dialog.getByLabel('First name').fill(createdMember.firstName) + await dialog.getByLabel('Last name').fill(createdMember.lastName) + await dialog.getByLabel('Email').fill(createdMember.email) + await dialog.getByLabel('Initial password').fill(createdMember.password) + await dialog.getByRole('button', { name: 'Next' }).click() + await dialog.getByRole('button', { name: 'Next' }).click() + await dialog.getByRole('button', { name: 'Create Member' }).click() + + await expect(toastRegion(page)).toContainText('Member created.') + await expect(page.getByRole('cell', { name: createdMemberName, exact: true })).toBeVisible() + + await navigateTo(page, '/feedback') + await page.getByRole('button', { name: 'New feedback' }).click() + const picker = page.getByRole('dialog', { name: 'New feedback' }) + await picker.getByLabel('Search members').fill(createdMemberName) + await expect( + picker.getByRole('button', { name: `Give feedback for ${createdMemberName}` }), + ).toBeVisible() +}) + +test('deleting a transaction recalculates the balances derived from it', async ({ page }) => { + await gotoApp(page, '/payments') + + await page.getByRole('tab', { name: 'Transactions' }).click() + const deleteButton = page.getByRole('button', { name: /^Delete / }).first() + await expect(deleteButton).toBeVisible() + const transactionRow = deleteButton.locator('xpath=ancestor::tr') + const memberName = (await transactionRow.getByRole('cell').nth(1).textContent())?.trim() + if (!memberName) throw new Error('The deletable transaction must identify its member') + + await page.getByRole('tab', { name: 'Balances' }).click() + const balanceRow = page.getByRole('button', { name: memberName, exact: false }) + await expect(balanceRow).toBeVisible() + const balanceBefore = await balanceRow.textContent() + + await page.getByRole('tab', { name: 'Transactions' }).click() + await deleteButton.click() + const confirm = page.getByRole('alertdialog', { name: 'Delete transaction' }) + await confirm.getByRole('button', { name: 'Delete', exact: true }).click() + await expect(toastRegion(page)).toContainText('Transaction deleted.') + + await page.getByRole('tab', { name: 'Balances' }).click() + await expect(balanceRow).toBeVisible() + await expect(balanceRow).not.toHaveText(balanceBefore ?? '') +}) diff --git a/web-client/e2e/events.spec.ts b/web-client/e2e/events.spec.ts index ea0c8ef0..cc16b3cc 100644 --- a/web-client/e2e/events.spec.ts +++ b/web-client/e2e/events.spec.ts @@ -1,5 +1,5 @@ import { eventSummaryFixtures } from './support/data' -import { expect, gotoApp, test } from './support/fixtures' +import { expect, gotoApp, test, toastRegion } from './support/fixtures' test('creates an event through the four-step dialog', async ({ page }) => { await gotoApp(page, '/sport-events') @@ -9,9 +9,7 @@ test('creates an event through the four-step dialog', async ({ page }) => { await dialog.getByLabel('Name').fill('E2E Friendly Match') await dialog.getByRole('button', { name: 'Next' }).click() - // Schedule step is pre-filled with a valid start/end pair. await dialog.getByRole('button', { name: 'Next' }).click() - // Sports & teams and attendees are optional for the admin. await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Create Event' }).click() @@ -21,7 +19,7 @@ test('creates an event through the four-step dialog', async ({ page }) => { await expect(sheet).toBeVisible() await sheet.getByRole('button', { name: 'Close' }).click() - await expect(page.getByRole('status')).toContainText('Event created.') + await expect(toastRegion(page)).toContainText('Event created.') await expect(page.getByRole('button', { name: 'View E2E Friendly Match' })).toBeVisible() }) @@ -51,7 +49,7 @@ test('edits an event name from the list', async ({ page }) => { await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Save Event' }).click() - await expect(page.getByRole('status')).toContainText('Event updated.') + await expect(toastRegion(page)).toContainText('Event updated.') await expect(page.getByRole('button', { name: 'View Renamed E2E Session' })).toBeVisible() }) @@ -64,5 +62,5 @@ test('deletes an event after confirmation', async ({ page }) => { const confirm = page.getByRole('alertdialog') await confirm.getByRole('button', { name: 'Delete', exact: true }).click() - await expect(page.getByRole('status')).toContainText('Event deleted.') + await expect(toastRegion(page)).toContainText('Event deleted.') }) diff --git a/web-client/e2e/feedback.spec.ts b/web-client/e2e/feedback.spec.ts index 1bee189c..fdeeb169 100644 --- a/web-client/e2e/feedback.spec.ts +++ b/web-client/e2e/feedback.spec.ts @@ -1,5 +1,5 @@ import { feedbackSummaryFixtures } from './support/data' -import { expect, gotoApp, test } from './support/fixtures' +import { expect, gotoApp, test, toastRegion } from './support/fixtures' test('composes feedback via the sport → team → event picker', async ({ page }) => { await gotoApp(page, '/feedback') @@ -7,12 +7,12 @@ test('composes feedback via the sport → team → event picker', async ({ page await page.getByRole('button', { name: 'New feedback' }).click() const picker = page.getByRole('dialog', { name: 'New feedback' }) - // Drill down the coverage tree: first sport → first team → first event. + await picker.getByRole('tab', { name: 'By event' }).click() + for (let level = 0; level < 3; level += 1) { await picker.locator('ul > li button').first().click() } - // Remember who we are giving feedback to, then open the compose dialog. const traineeRow = picker.locator('ul > li').first() const traineeName = (await traineeRow.locator('p').first().innerText()).trim() await traineeRow.getByRole('button', { name: `Give feedback for ${traineeName}` }).click() @@ -23,12 +23,24 @@ test('composes feedback via the sport → team → event picker', async ({ page await compose.getByLabel('Rating').fill('9') await compose.getByRole('button', { name: 'Save Feedback' }).click() - await expect(page.getByRole('status')).toContainText(`Feedback added for ${traineeName}.`) + await expect(toastRegion(page)).toContainText(`Feedback added for ${traineeName}.`) await expect( page.getByRole('button', { name: `View feedback for ${traineeName}` }).first(), ).toBeVisible() }) +test('both feedback picker tabs are real tabs and reach compose', async ({ page }) => { + await gotoApp(page, '/feedback') + await page.getByRole('button', { name: 'New feedback' }).click() + const picker = page.getByRole('dialog', { name: 'New feedback' }) + await expect(picker.getByRole('tab', { name: 'By member' })).toBeVisible() + await expect(picker.getByRole('tab', { name: 'By event' })).toBeVisible() + + await picker.getByRole('tab', { name: 'By member' }).click() + await picker.locator('ul > li').first().getByRole('button').click() + await expect(page.getByRole('dialog', { name: 'Give Feedback' })).toBeVisible() +}) + test('edits existing feedback from the detail sheet', async ({ page }) => { await gotoApp(page, '/feedback') diff --git a/web-client/e2e/helper.spec.ts b/web-client/e2e/helper.spec.ts index 2a16e538..e9db65e3 100644 --- a/web-client/e2e/helper.spec.ts +++ b/web-client/e2e/helper.spec.ts @@ -6,8 +6,9 @@ test('generates a report', async ({ page }) => { await page.getByRole('button', { name: 'Generate report' }).click() - await expect(page.getByText('Report generation started')).toBeVisible() + await expect(page.getByRole('status')).toContainText('Generating your report') await expect(page.getByRole('button', { name: `Read report for ${ADMIN.name}` })).toBeVisible() + await expect(page.getByRole('status')).toHaveCount(0) }) test('views a report', async ({ page }) => { diff --git a/web-client/e2e/letters.spec.ts b/web-client/e2e/letters.spec.ts index 1c9fdea4..b3091d80 100644 --- a/web-client/e2e/letters.spec.ts +++ b/web-client/e2e/letters.spec.ts @@ -1,15 +1,13 @@ -import { expect, gotoApp, test } from './support/fixtures' +import { expect, gotoApp, test, toastRegion } from './support/fixtures' test('renders a live preview with sample recipient data', async ({ page }) => { await gotoApp(page, '/letters') - // Admin audience goes to the whole club. await expect(page.getByText('This will go to all members.')).toBeVisible() await page.getByLabel('Template').fill('

Hello {{first_name}}, welcome to the club.

') const preview = page.frameLocator('iframe[title="Letter template sample preview"]') - // Tokens are substituted with the documented sample values ({{first_name}} -> Alex). await expect(preview.getByText('Hello Alex, welcome to the club.')).toBeVisible() }) @@ -20,7 +18,7 @@ test('sends a mail letter to the admin audience', async ({ page }) => { await page.getByLabel('Template').fill('

Hello {{first_name}}

See you at training.

') await page.getByRole('button', { name: 'Send Mail' }).click() - await expect(page.getByRole('status')).toContainText('Mail sent to all members.') + await expect(toastRegion(page)).toContainText('Mail sent to all members.') }) test('switches to PDF mode and hides the subject field', async ({ page }) => { diff --git a/web-client/e2e/members.spec.ts b/web-client/e2e/members.spec.ts index 2cb31c8a..fb9f2292 100644 --- a/web-client/e2e/members.spec.ts +++ b/web-client/e2e/members.spec.ts @@ -1,15 +1,16 @@ -import { memberSummaryFixtures } from './support/data' -import { expect, gotoApp, test } from './support/fixtures' +import { memberFixtures, memberSummaryFixtures } from './support/data' +import { expect, gotoApp, test, toastRegion } from './support/fixtures' -// Admin members CRUD. Each test gets a pristine server copy (stubApi resets it), -// so the specs are order-independent. + +const targetMember = memberFixtures[0] +if (!targetMember) throw new Error('memberFixtures must contain at least one member') +const targetMemberName = `${targetMember.first_name} ${targetMember.last_name}` test('lists every member for the admin', async ({ page }) => { await gotoApp(page, '/members') await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible() - await expect(page.getByRole('cell', { name: 'Lena Roth', exact: true })).toBeVisible() - // Admin scope = all rows (one table row per member summary + 1 header row). + await expect(page.getByRole('cell', { name: targetMemberName, exact: true })).toBeVisible() await expect(page.getByRole('row')).toHaveCount(memberSummaryFixtures.length + 1) }) @@ -26,51 +27,109 @@ test('creates a member through the stepper dialog', async ({ page }) => { await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Create Member' }).click() - await expect(page.getByRole('status')).toContainText('Member created.') + await expect(toastRegion(page)).toContainText('Member created.') await expect(page.getByRole('cell', { name: 'E2E Testperson', exact: true })).toBeVisible() }) -test('surfaces the 409 duplicate-email error inside the dialog', async ({ page }) => { +test('surfaces the 409 duplicate-email error and keeps the dialog open', async ({ page }) => { await gotoApp(page, '/members') await page.getByRole('button', { name: 'New member' }).click() const dialog = page.getByRole('dialog', { name: 'New Member' }) await dialog.getByLabel('First name').fill('Duplicate') await dialog.getByLabel('Last name').fill('Email') - // lena.roth@club.de belongs to an existing fixture member -> 409. - await dialog.getByLabel('Email').fill('lena.roth@club.de') + await dialog.getByLabel('Email').fill(targetMember.email) await dialog.getByLabel('Initial password').fill('changeme-e2e') await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Create Member' }).click() - await expect(dialog).toContainText('Email already in use') + await expect(toastRegion(page)).toContainText(`Email already in use: ${targetMember.email}`) + await expect(dialog).toBeVisible() +}) + +test('keeps the member form open on outside click and closes through explicit controls', async ({ page }) => { + await gotoApp(page, '/members') + await page.getByRole('button', { name: 'New member' }).click() + let dialog = page.getByRole('dialog', { name: 'New Member' }) + await dialog.getByLabel('First name').fill('Unsaved') + await page.locator('[data-slot="dialog-overlay"]').click({ position: { x: 4, y: 4 } }) + await expect(dialog).toBeVisible() + await page.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + + await page.getByRole('button', { name: 'New member' }).click() + dialog = page.getByRole('dialog', { name: 'New Member' }) + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(dialog).toHaveCount(0) + + await page.getByRole('button', { name: 'New member' }).click() + dialog = page.getByRole('dialog', { name: 'New Member' }) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toHaveCount(0) +}) + +test('renders identity validation beside the invalid field on the identity step', async ({ page }) => { + await gotoApp(page, '/members') + await page.getByRole('button', { name: 'New member' }).click() + const dialog = page.getByRole('dialog', { name: 'New Member' }) + await dialog.getByLabel('Last name').fill('Only') + await dialog.getByLabel('Email').fill('only@example.test') + await dialog.getByLabel('Initial password').fill('changeme-e2e') + await dialog.getByRole('button', { name: 'Next' }).click() + await expect(dialog.getByLabel('First name')).toHaveAttribute('aria-invalid', 'true') + await expect(dialog).toContainText('First name is required.') + await expect(dialog.getByText('Identity')).toBeVisible() +}) + +test('keeps the editor open and shows the server message for a 403', async ({ page }) => { + await gotoApp(page, '/members') + await page.route('**/api/v1/members/*', async (route) => { + if (route.request().method() === 'PATCH') { + await route.fulfill({ + status: 403, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ message: 'Editing members is disabled for this test.' }), + }) + return + } + await route.fallback() + }) + await page.getByRole('button', { name: `Edit ${targetMemberName}` }).click() + const dialog = page.getByRole('dialog', { name: 'Edit Member' }) + await dialog.getByLabel('First name').fill('Blocked') + await dialog.getByRole('button', { name: 'Next' }).click() + await dialog.getByRole('button', { name: 'Next' }).click() + await dialog.getByRole('button', { name: 'Save Member' }).click() + await expect(toastRegion(page)).toContainText('Editing members is disabled for this test.') await expect(dialog).toBeVisible() }) test('edits a member and reflects the change in the table', async ({ page }) => { await gotoApp(page, '/members') - await page.getByRole('button', { name: 'Edit Lena Roth' }).click() + await page.getByRole('button', { name: `Edit ${targetMemberName}` }).click() const dialog = page.getByRole('dialog', { name: 'Edit Member' }) - await dialog.getByLabel('First name').fill('Magda') + const updatedFirstName = `Updated-${targetMember.first_name}` + const updatedMemberName = `${updatedFirstName} ${targetMember.last_name}` + await dialog.getByLabel('First name').fill(updatedFirstName) await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Save Member' }).click() - await expect(page.getByRole('status')).toContainText('Member updated.') - await expect(page.getByRole('cell', { name: 'Magda Roth', exact: true })).toBeVisible() - await expect(page.getByRole('cell', { name: 'Lena Roth', exact: true })).toHaveCount(0) + await expect(toastRegion(page)).toContainText('Member updated.') + await expect(page.getByRole('cell', { name: updatedMemberName, exact: true })).toBeVisible() + await expect(page.getByRole('cell', { name: targetMemberName, exact: true })).toHaveCount(0) }) test('deletes a member after confirmation', async ({ page }) => { await gotoApp(page, '/members') - await page.getByRole('button', { name: 'Delete Lena Roth' }).click() + await page.getByRole('button', { name: `Delete ${targetMemberName}` }).click() const confirm = page.getByRole('alertdialog', { name: 'Delete member' }) - await expect(confirm).toContainText('Delete Lena Roth?') + await expect(confirm).toContainText(`Delete ${targetMemberName}?`) await confirm.getByRole('button', { name: 'Delete', exact: true }).click() - await expect(page.getByRole('status')).toContainText('Member deleted.') - await expect(page.getByRole('cell', { name: 'Lena Roth', exact: true })).toHaveCount(0) + await expect(toastRegion(page)).toContainText('Member deleted.') + await expect(page.getByRole('cell', { name: targetMemberName, exact: true })).toHaveCount(0) }) diff --git a/web-client/e2e/organization.spec.ts b/web-client/e2e/organization.spec.ts index 1adde9fe..8251f7ba 100644 --- a/web-client/e2e/organization.spec.ts +++ b/web-client/e2e/organization.spec.ts @@ -1,5 +1,5 @@ import { sportFixtures, teamFixtures } from './support/data' -import { expect, gotoApp, test } from './support/fixtures' +import { expect, gotoApp, test, toastRegion } from './support/fixtures' test('creates a sport', async ({ page }) => { await gotoApp(page, '/organization') @@ -11,7 +11,7 @@ test('creates a sport', async ({ page }) => { await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Create Sport' }).click() - await expect(page.getByRole('status')).toContainText('Sport created.') + await expect(toastRegion(page)).toContainText('Sport created.') await expect(page.getByText('E2E Pickleball', { exact: true })).toBeVisible() }) @@ -26,7 +26,7 @@ test('edits a sport', async ({ page }) => { await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Save Sport' }).click() - await expect(page.getByRole('status')).toContainText('Sport updated.') + await expect(toastRegion(page)).toContainText('Sport updated.') await expect(page.getByText('E2E Football', { exact: true })).toBeVisible() await expect(page.getByText(target.name, { exact: true })).toHaveCount(0) }) @@ -40,7 +40,7 @@ test('deletes a sport', async ({ page }) => { await expect(confirm).toContainText(`Delete ${target.name}?`) await confirm.getByRole('button', { name: 'Delete sport' }).click() - await expect(page.getByRole('status')).toContainText('Sport deleted.') + await expect(toastRegion(page)).toContainText('Sport deleted.') await expect(page.getByText(target.name, { exact: true })).toHaveCount(0) }) @@ -56,7 +56,7 @@ test('creates a team', async ({ page }) => { await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Create Team' }).click() - await expect(page.getByRole('status')).toContainText('Team created.') + await expect(toastRegion(page)).toContainText('Team created.') await expect(page.getByText('E2E Morning Crew', { exact: true })).toBeVisible() }) @@ -64,22 +64,23 @@ test('edits and deletes a team', async ({ page }) => { await gotoApp(page, '/organization') const target = teamFixtures[0] + const updatedName = `E2E ${target.name}` await page.getByRole('button', { name: `Edit ${target.name}`, exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Edit Team' }) - await dialog.getByLabel('Name').fill('E2E Football Juniors') + await dialog.getByLabel('Name').fill(updatedName) await dialog.getByRole('button', { name: 'Next' }).click() await dialog.getByRole('button', { name: 'Save Team' }).click() - await expect(page.getByRole('status')).toContainText('Team updated.') - await expect(page.getByText('E2E Football Juniors', { exact: true })).toBeVisible() + await expect(toastRegion(page)).toContainText('Team updated.') + await expect(page.getByText(updatedName, { exact: true })).toBeVisible() await expect(page.getByText(target.name, { exact: true })).toHaveCount(0) - await page.getByRole('button', { name: 'Delete E2E Football Juniors' }).click() + await page.getByRole('button', { name: `Delete ${updatedName}` }).click() const confirm = page.getByRole('alertdialog', { name: 'Delete team' }) - await expect(confirm).toContainText('Delete E2E Football Juniors?') + await expect(confirm).toContainText(`Delete ${updatedName}?`) await confirm.getByRole('button', { name: 'Delete', exact: true }).click() - await expect(page.getByRole('status')).toContainText('Team deleted.') - await expect(page.getByText('E2E Football Juniors', { exact: true })).toHaveCount(0) + await expect(toastRegion(page)).toContainText('Team deleted.') + await expect(page.getByText(updatedName, { exact: true })).toHaveCount(0) }) diff --git a/web-client/e2e/pages.spec.ts b/web-client/e2e/pages.spec.ts index a199bed8..5715518c 100644 --- a/web-client/e2e/pages.spec.ts +++ b/web-client/e2e/pages.spec.ts @@ -9,8 +9,6 @@ import { } from './support/data' import { expect, gotoApp, test } from './support/fixtures' -// Every reachable page renders content derived from the fixtures + scope rules -// for the running admin identity (admin scope = everything). test('dashboard greets the admin and shows the admin count envelope', async ({ page }) => { await gotoApp(page) @@ -31,7 +29,6 @@ test('events page lists every fixture event for the admin', async ({ page }) => await gotoApp(page, '/sport-events') await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible() - // Admin scope = all rows; spot-check the first and last fixture events. const first = eventSummaryFixtures[0] const last = eventSummaryFixtures[eventSummaryFixtures.length - 1] await expect(page.getByRole('button', { name: `View ${first.name}` }).first()).toBeVisible() @@ -60,8 +57,8 @@ test('payments page shows the managed lens with fixture balances', async ({ page await gotoApp(page, '/payments') await expect(page.getByText('Member balances and transaction history.')).toBeVisible() - await expect(page.getByRole('heading', { name: 'Balances' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Transactions' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Balances', selected: true })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Transactions' })).toBeVisible() await expect(page.getByRole('button', { name: 'New Transaction' })).toBeVisible() }) diff --git a/web-client/e2e/payments.spec.ts b/web-client/e2e/payments.spec.ts index 7681be82..85773ba2 100644 --- a/web-client/e2e/payments.spec.ts +++ b/web-client/e2e/payments.spec.ts @@ -1,5 +1,34 @@ +import { balanceFixtures, transactionFixtures } from './support/data' import { expect, gotoApp, test } from './support/fixtures' +const paymentMember = transactionFixtures.find((transaction) => + balanceFixtures.some((balance) => balance.member.id === transaction.member.id), +)?.member +if (!paymentMember) { + throw new Error('fixtures must contain a transaction member with a balance') +} + +test('clicking a balance row jumps to Transactions filtered to that member', async ({ + page, +}) => { + await gotoApp(page, '/payments') + + await expect(page.getByRole('tab', { name: 'Balances', selected: true })).toBeVisible() + + await page.getByRole('searchbox', { name: 'Search members' }).fill(paymentMember.name) + const row = page.getByRole('button', { name: paymentMember.name, exact: false }) + await row.click() + + await expect(page.getByRole('tab', { name: 'Transactions', selected: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Clear Member' })).toBeVisible() + await expect( + page.getByRole('cell', { name: paymentMember.name, exact: true }).first(), + ).toBeVisible() + + await page.getByRole('button', { name: 'Clear Member' }).click() + await expect(page.getByRole('button', { name: 'Clear Member' })).toHaveCount(0) +}) + test('shows a field error when no member is selected', async ({ page }) => { await gotoApp(page, '/payments') @@ -21,15 +50,16 @@ test('records a transaction via the searchable member combobox', async ({ page } const dialog = page.getByRole('dialog', { name: 'New Transaction' }) await dialog.getByRole('combobox', { name: 'Member' }).click() - await page.getByRole('combobox', { name: 'Search members' }).fill('Marie Wolf') - await page.getByRole('option', { name: 'Marie Wolf' }).click() + await page.getByRole('combobox', { name: 'Search members' }).fill(paymentMember.name) + await page.getByRole('option', { name: paymentMember.name }).click() await dialog.getByRole('button', { name: 'Payment' }).click() await dialog.getByLabel('Amount').fill('25.50') await dialog.getByLabel('Title').fill('E2E membership payment') await dialog.getByRole('button', { name: 'Record Transaction' }).click() - await expect(page.getByRole('status')).toContainText('Transaction recorded for Marie Wolf.') + await expect(page.getByText(`Transaction recorded for ${paymentMember.name}.`)).toBeVisible() + await page.getByRole('tab', { name: 'Transactions' }).click() await expect( page.getByRole('cell', { name: 'E2E membership payment', exact: true }), ).toBeVisible() @@ -42,8 +72,8 @@ test('rejects a malformed amount with a form error', async ({ page }) => { const dialog = page.getByRole('dialog', { name: 'New Transaction' }) await dialog.getByRole('combobox', { name: 'Member' }).click() - await page.getByRole('combobox', { name: 'Search members' }).fill('Marie Wolf') - await page.getByRole('option', { name: 'Marie Wolf' }).click() + await page.getByRole('combobox', { name: 'Search members' }).fill(paymentMember.name) + await page.getByRole('option', { name: paymentMember.name }).click() await dialog.getByLabel('Amount').fill('not-a-number') await dialog.getByLabel('Title').fill('E2E fee') await dialog.getByRole('button', { name: 'Record Transaction' }).click() @@ -53,6 +83,7 @@ test('rejects a malformed amount with a form error', async ({ page }) => { test('deletes a transaction from the managed table', async ({ page }) => { await gotoApp(page, '/payments') + await page.getByRole('tab', { name: 'Transactions' }).click() // Admin-created fixture transactions expose a delete action. Wait for the first row's // delete button so the count below isn't read before the table has loaded. @@ -65,5 +96,5 @@ test('deletes a transaction from the managed table', async ({ page }) => { const confirm = page.getByRole('alertdialog', { name: 'Delete transaction' }) await confirm.getByRole('button', { name: 'Delete', exact: true }).click() - await expect(page.getByRole('status')).toContainText('Transaction deleted.') + await expect(page.getByText('Transaction deleted.')).toBeVisible() }) diff --git a/web-client/e2e/profile.spec.ts b/web-client/e2e/profile.spec.ts index a397c240..f23e1502 100644 --- a/web-client/e2e/profile.spec.ts +++ b/web-client/e2e/profile.spec.ts @@ -1,5 +1,5 @@ import { ADMIN, memberFixtures } from './support/data' -import { expect, gotoApp, test } from './support/fixtures' +import { expect, gotoApp, test, toastRegion } from './support/fixtures' test('edits and saves the own record', async ({ page }) => { await gotoApp(page, '/profile') @@ -16,7 +16,7 @@ test('edits and saves the own record', async ({ page }) => { await expect(save).toBeEnabled() await save.click() - await expect(page.getByRole('status')).toContainText('Profile updated.') + await expect(toastRegion(page)).toContainText('Profile updated.') await expect(phone).toHaveValue('+49 170 000000') await expect(save).toBeDisabled() }) diff --git a/web-client/e2e/support/api.ts b/web-client/e2e/support/api.ts index 9e4aa40f..49ec45d2 100644 --- a/web-client/e2e/support/api.ts +++ b/web-client/e2e/support/api.ts @@ -11,9 +11,7 @@ import * as helper from './server/helper' import * as letters from './server/letters' import * as dashboard from './server/dashboard' -// Intercepts the app's axios calls at the network layer (context.route on **/api/v1/**) -// and answers them from an in-memory server, so no real services run. The server keeps -// module-level state (deep clones of the fixtures); stubApi() resets it per test. +// Module state is safe because workers are separate processes and tests are serial within a worker. const USER: AuthUser = { id: E2E_USER.sub, @@ -37,7 +35,6 @@ function resetServer(): void { interface Parsed { method: string - // path segments after /api/v1, e.g. ['members', ''] or ['finance', 'transactions'] segments: string[] body: unknown } @@ -56,6 +53,66 @@ function parse(route: Route): Parsed { return { method: request.method(), segments, body } } +function updateMember(id: string, body: never) { + const updated = server.members.updateMember(id, body, USER) + const name = `${updated.first_name} ${updated.last_name}` + server.organization.renameMemberInOrganization(id, name) + server.feedback.renameMemberInFeedback(id, name) + server.events.renameMemberInEvents(id, name) + server.payments.renameMemberInPayments(id, name) + server.helper.renameMemberInReports(id, name) + return updated +} + +function deleteMember(id: string): void { + server.members.deleteMember(id, USER) + server.organization.removeMemberFromOrganization(id) + server.feedback.removeMemberFromFeedback(id) + server.events.removeMemberFromEvents(id) + server.payments.removeMemberFromPayments(id) + server.helper.removeMemberFromReports(id) +} + +function updateEvent(id: string, body: never) { + const updated = server.events.updateEvent(id, body, USER) + server.feedback.renameEventInFeedback(id, updated.name) + return updated +} + +function deleteEvent(id: string): void { + server.events.deleteEvent(id, USER) + server.feedback.removeEventFromFeedback(id) +} + +function updateSport(id: string, body: never) { + const updated = server.organization.updateSport(id, body, USER) + server.events.renameSportInEvents(id, updated.name) + return updated +} + +function deleteSport(id: string): void { + const teamIds = server.organization.teamIdsForSport(id) + server.organization.deleteSport(id, USER) + server.events.removeSportFromEvents(id) + for (const teamId of teamIds) { + server.events.removeTeamFromEvents(teamId) + server.helper.removeTeamFromReports(teamId) + } +} + +async function updateTeam(id: string, body: never) { + const updated = await server.organization.updateTeam(id, body, USER) + server.events.renameTeamInEvents(id, updated.name) + server.helper.renameTeamInReports(id, updated.name) + return updated +} + +function deleteTeam(id: string): void { + server.organization.deleteTeam(id, USER) + server.events.removeTeamFromEvents(id) + server.helper.removeTeamFromReports(id) +} + async function run(route: Route, produce: () => unknown | Promise): Promise { try { const result = await produce() @@ -80,20 +137,18 @@ async function run(route: Route, produce: () => unknown | Promise): Pro } } -// Routes one intercepted request to the in-memory server. A thrown httpError() carries its status. function dispatch({ method, segments, body }: Parsed): unknown | Promise { const [service, a, b] = segments switch (service) { case 'members': { - // dashboard lives under members: GET /members/dashboard if (a === 'dashboard') return server.dashboard.getDashboard(USER) if (method === 'GET' && a === 'hello') return 'members' if (method === 'GET' && !a) return server.members.listMembers(USER) if (method === 'GET') return server.members.getMember(a, USER) if (method === 'POST') return server.members.createMember(body as never, USER) - if (method === 'PATCH') return server.members.updateMember(a, body as never, USER) - if (method === 'DELETE') return server.members.deleteMember(a, USER) + if (method === 'PATCH') return updateMember(a, body as never) + if (method === 'DELETE') return deleteMember(a) break } case 'events': { @@ -101,8 +156,8 @@ function dispatch({ method, segments, body }: Parsed): unknown | Promise { await page.goto(path) await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible({ timeout: 15_000 }) } + +// Sonner renders its toasts inside a single labelled aria-live region (see ui/sonner.tsx). +// Assert toast copy through this rather than a bare role, so we don't need a second live +// region wrapped around it just to be queryable. +export function toastRegion(page: Page) { + return page.getByRole('region', { name: /Notifications/ }) +} diff --git a/web-client/e2e/support/server/dashboard.ts b/web-client/e2e/support/server/dashboard.ts index cbff6ebe..10bea19b 100644 --- a/web-client/e2e/support/server/dashboard.ts +++ b/web-client/e2e/support/server/dashboard.ts @@ -1,7 +1,6 @@ import { dashboardForUser } from '@/testing/fixtures/dashboard' import type { AuthUser, Dashboard } from '@/types' -// In-memory dashboard resource. export function getDashboard(user: AuthUser): Dashboard { return dashboardForUser(user) } diff --git a/web-client/e2e/support/server/events.ts b/web-client/e2e/support/server/events.ts index 46f402ff..0c42ef91 100644 --- a/web-client/e2e/support/server/events.ts +++ b/web-client/e2e/support/server/events.ts @@ -10,19 +10,13 @@ import { scopeEvents } from '@/testing/scope' import type { AuthUser, EventListItem, Reference } from '@/types' import type { SportEvent, EventCreate, EventPartialUpdate } from '@/features/sport-events/types' -// In-memory events resource; reset() restores the module-level state per test. let eventDetailsState: Record = {} let eventSummaryState: EventListItem[] = [] export function reset(): void { - eventDetailsState = { ...eventDetailsById } - eventSummaryState = eventSummaryFixtures.map((event) => ({ - ...event, - attendees: event.attendees?.map((attendee) => ({ ...attendee })), - sports_linked: event.sports_linked?.map((sport) => ({ ...sport })), - teams_linked: event.teams_linked?.map((team) => ({ ...team })), - })) + eventDetailsState = structuredClone(eventDetailsById) + eventSummaryState = structuredClone(eventSummaryFixtures) } reset() @@ -206,3 +200,74 @@ export function deleteEvent(id: string, user: AuthUser): void { delete eventDetailsState[id] eventSummaryState = eventSummaryState.filter((row) => row.id !== id) } + +export function renameMemberInEvents(memberId: string, name: string): void { + const rename = (ref: Reference) => (ref.id === memberId ? { ...ref, name } : ref) + for (const event of Object.values(eventDetailsState)) { + event.attendees = event.attendees?.map(rename) + event.creator = event.creator ? rename(event.creator) : null + } + eventSummaryState = eventSummaryState.map((event) => ({ + ...event, + attendees: event.attendees?.map(rename), + })) +} + +export function removeMemberFromEvents(memberId: string): void { + for (const event of Object.values(eventDetailsState)) { + event.attendees = event.attendees?.filter((attendee) => attendee.id !== memberId) + if (event.creator?.id === memberId) event.creator = null + } + eventSummaryState = eventSummaryState.map((event) => ({ + ...event, + attendees: event.attendees?.filter((attendee) => attendee.id !== memberId), + })) +} + +export function renameSportInEvents(sportId: string, name: string): void { + for (const event of Object.values(eventDetailsState)) { + event.sports_linked = event.sports_linked?.map((sport) => + sport.id === sportId ? { ...sport, name } : sport, + ) + } + eventSummaryState = eventSummaryState.map((event) => ({ + ...event, + sports_linked: event.sports_linked?.map((sport) => + sport.id === sportId ? { ...sport, name } : sport, + ), + })) +} + +export function removeSportFromEvents(sportId: string): void { + for (const event of Object.values(eventDetailsState)) { + event.sports_linked = event.sports_linked?.filter((sport) => sport.id !== sportId) + } + eventSummaryState = eventSummaryState.map((event) => ({ + ...event, + sports_linked: event.sports_linked?.filter((sport) => sport.id !== sportId), + })) +} + +export function renameTeamInEvents(teamId: string, name: string): void { + for (const event of Object.values(eventDetailsState)) { + event.teams_linked = event.teams_linked?.map((team) => + team.id === teamId ? { ...team, name } : team, + ) + } + eventSummaryState = eventSummaryState.map((event) => ({ + ...event, + teams_linked: event.teams_linked?.map((team) => + team.id === teamId ? { ...team, name } : team, + ), + })) +} + +export function removeTeamFromEvents(teamId: string): void { + for (const event of Object.values(eventDetailsState)) { + event.teams_linked = event.teams_linked?.filter((team) => team.id !== teamId) + } + eventSummaryState = eventSummaryState.map((event) => ({ + ...event, + teams_linked: event.teams_linked?.filter((team) => team.id !== teamId), + })) +} diff --git a/web-client/e2e/support/server/feedback.ts b/web-client/e2e/support/server/feedback.ts index e3edc0bf..a21acbf3 100644 --- a/web-client/e2e/support/server/feedback.ts +++ b/web-client/e2e/support/server/feedback.ts @@ -15,14 +15,13 @@ import type { FeedbackSummary, } from '@/features/feedback/types' -// In-memory feedback resource; reset() restores the module-level state per test. let feedbackDetailsState: Record = {} let feedbackSummaryState: FeedbackSummary[] = [] export function reset(): void { - feedbackDetailsState = { ...feedbackDetailsById } - feedbackSummaryState = [...feedbackSummaryFixtures] + feedbackDetailsState = structuredClone(feedbackDetailsById) + feedbackSummaryState = structuredClone(feedbackSummaryFixtures) } reset() @@ -137,6 +136,58 @@ export function updateFeedback( return updated } +export function renameMemberInFeedback(memberId: string, name: string): void { + const rename = (ref: T): T => + ref && ref.id === memberId ? ({ ...ref, name } as T) : ref + + for (const [id, feedback] of Object.entries(feedbackDetailsState)) { + feedbackDetailsState[id] = { + ...feedback, + member: rename(feedback.member), + creator: rename(feedback.creator), + } + } + + feedbackSummaryState = feedbackSummaryState.map((row) => ({ + ...row, + member: rename(row.member), + creator: rename(row.creator), + })) +} + +export function removeMemberFromFeedback(memberId: string): void { + for (const [id, feedback] of Object.entries(feedbackDetailsState)) { + if (feedback.member?.id === memberId) { + delete feedbackDetailsState[id] + } else if (feedback.creator?.id === memberId) { + feedbackDetailsState[id] = { ...feedback, creator: null } + } + } + + feedbackSummaryState = feedbackSummaryState + .filter((row) => row.member?.id !== memberId) + .map((row) => (row.creator?.id === memberId ? { ...row, creator: null } : row)) +} + +export function renameEventInFeedback(eventId: string, name: string): void { + for (const [id, feedback] of Object.entries(feedbackDetailsState)) { + if (feedback.event.id === eventId) { + feedbackDetailsState[id] = { ...feedback, event: { ...feedback.event, name } } + } + } + + feedbackSummaryState = feedbackSummaryState.map((row) => + row.event.id === eventId ? { ...row, event: { ...row.event, name } } : row, + ) +} + +export function removeEventFromFeedback(eventId: string): void { + for (const [id, feedback] of Object.entries(feedbackDetailsState)) { + if (feedback.event.id === eventId) delete feedbackDetailsState[id] + } + feedbackSummaryState = feedbackSummaryState.filter((row) => row.event.id !== eventId) +} + export function deleteFeedback(id: string, user: AuthUser): void { const existing = feedbackDetailsState[id] ?? feedbackSummaryState.find((row) => row.id === id) diff --git a/web-client/e2e/support/server/helper.ts b/web-client/e2e/support/server/helper.ts index bb8bc443..4a304324 100644 --- a/web-client/e2e/support/server/helper.ts +++ b/web-client/e2e/support/server/helper.ts @@ -9,20 +9,15 @@ import { httpError } from '@/testing/httpError' import { scopeReport, scopeTeamReport } from '@/testing/scope' import type { AuthUser, MemberReportSummary, Report, TeamReportSummary } from '@/types' -// In-memory helper (reports) resource; reset() restores the module-level state per test. let reportState: Record = {} let memberReportSummariesState: Record = {} let teamReportSummariesState: Record = {} export function reset(): void { - reportState = { ...reportByIdFixture } - memberReportSummariesState = Object.fromEntries( - Object.entries(memberReportSummariesById).map(([memberId, rows]) => [memberId, [...rows]]), - ) - teamReportSummariesState = Object.fromEntries( - Object.entries(teamReportSummariesById).map(([teamId, rows]) => [teamId, [...rows]]), - ) + reportState = structuredClone(reportByIdFixture) + memberReportSummariesState = structuredClone(memberReportSummariesById) + teamReportSummariesState = structuredClone(teamReportSummariesById) } reset() @@ -111,3 +106,41 @@ export function deleteReport(reportId: string): void { ) } } + +export function renameMemberInReports(memberId: string, name: string): void { + memberReportSummariesState[memberId] = (memberReportSummariesState[memberId] ?? []).map((row) => ({ + ...row, + member: { ...row.member, name }, + })) + for (const [id, report] of Object.entries(reportState)) { + if (report.member?.id === memberId) { + reportState[id] = { ...report, member: { ...report.member, name } } + } + } +} + +export function removeMemberFromReports(memberId: string): void { + delete memberReportSummariesState[memberId] + for (const [id, report] of Object.entries(reportState)) { + if (report.member?.id === memberId) delete reportState[id] + } +} + +export function renameTeamInReports(teamId: string, name: string): void { + teamReportSummariesState[teamId] = (teamReportSummariesState[teamId] ?? []).map((row) => ({ + ...row, + team: { ...row.team, name }, + })) + for (const [id, report] of Object.entries(reportState)) { + if (report.team?.id === teamId) { + reportState[id] = { ...report, team: { ...report.team, name } } + } + } +} + +export function removeTeamFromReports(teamId: string): void { + delete teamReportSummariesState[teamId] + for (const [id, report] of Object.entries(reportState)) { + if (report.team?.id === teamId) delete reportState[id] + } +} diff --git a/web-client/e2e/support/server/letters.ts b/web-client/e2e/support/server/letters.ts index d40913bf..b6ace421 100644 --- a/web-client/e2e/support/server/letters.ts +++ b/web-client/e2e/support/server/letters.ts @@ -1,13 +1,11 @@ import type { GeneratePdfRequest } from '@/features/letters/types' -// In-memory letters resource: acknowledges sends and echoes a PDF blob from the template. export function lettersHello(): string { return 'letters' } export function sendMail(): void { - // no-op: accepts any send } export function generatePdf(data: GeneratePdfRequest): Blob { diff --git a/web-client/e2e/support/server/members.ts b/web-client/e2e/support/server/members.ts index 5a43b456..ca80fb84 100644 --- a/web-client/e2e/support/server/members.ts +++ b/web-client/e2e/support/server/members.ts @@ -6,13 +6,12 @@ import { httpError } from '@/testing/httpError' import { scopeMembers } from '@/testing/scope' import type { AuthUser, Member, MemberCreate, MemberPartialUpdate, MemberSummary } from '@/types' -// In-memory members resource. State is a deep clone of the fixtures; reset() restores it per test. let memberFixtures: Member[] = [] let memberSummaryFixtures: MemberSummary[] = [] export function reset(): void { - memberFixtures = memberFixturesSource.map((m) => ({ ...m })) - memberSummaryFixtures = memberSummaryFixturesSource.map((m) => ({ ...m })) + memberFixtures = structuredClone(memberFixturesSource) + memberSummaryFixtures = structuredClone(memberSummaryFixturesSource) } reset() @@ -105,6 +104,7 @@ export function updateMember( email: updated.email, } } + return { ...updated } } diff --git a/web-client/e2e/support/server/organization.ts b/web-client/e2e/support/server/organization.ts index 11ea301a..56aa6a25 100644 --- a/web-client/e2e/support/server/organization.ts +++ b/web-client/e2e/support/server/organization.ts @@ -14,7 +14,6 @@ import type { TeamPartialUpdate, } from '@/features/organization/types' -// In-memory organization resource. State is a deep clone of the fixtures; reset() restores it per test. let sportFixtures: Sport[] = [] let teamFixtures: Team[] = [] let sportsById: Record = {} @@ -124,6 +123,10 @@ export function deleteSport(id: string, user: AuthUser): void { } } +export function teamIdsForSport(sportId: string): string[] { + return teamFixtures.filter((team) => team.sport.id === sportId).map((team) => team.id) +} + export function createTeam(data: TeamCreate, user: AuthUser): Team { const name = data.name.trim() @@ -203,6 +206,31 @@ export function updateTeam( } } +// Each service owns member replicas, so renames must propagate through embedded refs. +export function renameMemberInOrganization(memberId: string, name: string): void { + const rename = (ref: MemberRef) => (ref.id === memberId ? { ...ref, name } : ref) + + for (const team of teamFixtures) { + team.trainers = team.trainers.map(rename) + team.trainees = team.trainees.map(rename) + } + + for (const sport of sportFixtures) { + sport.directors = sport.directors.map(rename) + } +} + +export function removeMemberFromOrganization(memberId: string): void { + for (const team of teamFixtures) { + team.trainers = team.trainers.filter((trainer) => trainer.id !== memberId) + team.trainees = team.trainees.filter((trainee) => trainee.id !== memberId) + } + + for (const sport of sportFixtures) { + sport.directors = sport.directors.filter((director) => director.id !== memberId) + } +} + export function deleteTeam(id: string, user: AuthUser): void { const index = teamFixtures.findIndex((team) => team.id === id) const team = teamFixtures[index] diff --git a/web-client/e2e/support/server/payments.ts b/web-client/e2e/support/server/payments.ts index ca6db791..0943f7e8 100644 --- a/web-client/e2e/support/server/payments.ts +++ b/web-client/e2e/support/server/payments.ts @@ -18,13 +18,12 @@ import type { TransactionPartialUpdate, } from '@/features/payments/types' -// In-memory payments resource. State is a deep clone of the fixtures; reset() restores it per test. let transactionFixtures: Transaction[] = [] let balanceFixtures: Balance[] = [] export function reset(): void { - transactionFixtures = transactionFixturesSource.map(cloneTransaction) - balanceFixtures = balanceFixturesSource.map((b) => ({ ...b, member: { ...b.member } })) + transactionFixtures = structuredClone(transactionFixturesSource) + balanceFixtures = structuredClone(balanceFixturesSource) } reset() @@ -85,7 +84,7 @@ export function updateTransaction( const existing = transactionFixtures[index] if (!existing) throw httpError(404, 'Transaction not found.') - // Same gate delete uses: only the creator or an admin may modify a transaction. + // Only the creator or an admin may modify a transaction. if (user.role !== 'admin' && existing.creator?.id !== user.id) { throw httpError(403, 'You are not allowed to update this transaction.') } @@ -129,6 +128,28 @@ export function deleteTransaction(id: string, user: AuthUser): void { syncMockBalance(transaction.member) } +export function renameMemberInPayments(memberId: string, name: string): void { + transactionFixtures = transactionFixtures.map((transaction) => ({ + ...transaction, + member: transaction.member.id === memberId ? { ...transaction.member, name } : transaction.member, + creator: transaction.creator?.id === memberId ? { ...transaction.creator, name } : transaction.creator, + })) + balanceFixtures = balanceFixtures.map((balance) => + balance.member.id === memberId + ? { ...balance, member: { ...balance.member, name } } + : balance, + ) +} + +export function removeMemberFromPayments(memberId: string): void { + transactionFixtures = transactionFixtures + .filter((transaction) => transaction.member.id !== memberId) + .map((transaction) => + transaction.creator?.id === memberId ? { ...transaction, creator: null } : transaction, + ) + balanceFixtures = balanceFixtures.filter((balance) => balance.member.id !== memberId) +} + function memberRef(memberId: string): Reference { const name = memberNamesById[memberId] if (!name) throw httpError(404, 'Member not found.') diff --git a/web-client/package.json b/web-client/package.json index 32955900..56412f5b 100644 --- a/web-client/package.json +++ b/web-client/package.json @@ -18,7 +18,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", - "e2e": "playwright test", + "e2e": "pnpm build && playwright test", "e2e:ui": "playwright test --ui" }, "dependencies": { @@ -40,9 +40,11 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", "react-hook-form": "^7.77.0", + "react-phone-number-input": "^3.4.17", "react-router-dom": "^7.15.1", "rolldown": "^1.0.2", "rollup": "^4.60.4", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "tslib": "^2.8.1", diff --git a/web-client/playwright.config.ts b/web-client/playwright.config.ts index 46aabee6..b8c6521a 100644 --- a/web-client/playwright.config.ts +++ b/web-client/playwright.config.ts @@ -11,8 +11,12 @@ export default defineConfig({ testDir: './e2e', outputDir: './test-results', fullyParallel: true, + // Eight Chromium contexts saturate this machine and starve the preview server, + // causing auth/app hydration to miss the existing 30s test budget. Four keeps + // tests fully parallel while leaving capacity for the browser and web server. + workers: 4, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, + retries: 0, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', timeout: 30_000, expect: { timeout: 10_000 }, @@ -22,7 +26,9 @@ export default defineConfig({ }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], webServer: { - command: `pnpm exec vite --port ${PORT} --strictPort`, + // Preview serves the already-built bundle without Vite's per-worker transform + // contention. Run `pnpm build` before E2E (the e2e script does this). + command: `pnpm exec vite preview --port ${PORT} --strictPort`, url: `http://localhost:${PORT}`, reuseExistingServer: !process.env.CI, timeout: 60_000, diff --git a/web-client/pnpm-lock.yaml b/web-client/pnpm-lock.yaml index 3aee697c..43b7220f 100644 --- a/web-client/pnpm-lock.yaml +++ b/web-client/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: react-hook-form: specifier: ^7.77.0 version: 7.77.0(react@19.2.6) + react-phone-number-input: + specifier: ^3.4.17 + version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: specifier: ^7.15.1 version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -71,6 +74,9 @@ importers: rollup: specifier: ^4.60.4 version: 4.60.4 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -2180,6 +2186,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2271,6 +2280,9 @@ packages: typescript: optional: true + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2815,6 +2827,17 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -3003,6 +3026,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.8: + resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3084,6 +3110,10 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3393,6 +3423,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3454,6 +3487,15 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-phone-number-input@3.4.17: + resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3641,6 +3683,12 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6148,6 +6196,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -6222,6 +6272,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + country-flag-icons@1.6.20: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6799,6 +6851,13 @@ snapshots: inherits@2.0.4: {} + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -6957,6 +7016,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.8: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -7017,6 +7078,10 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -7308,6 +7373,12 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -7412,6 +7483,18 @@ snapshots: dependencies: react: 19.2.6 + react-is@16.13.1: {} + + react-phone-number-input@3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.20 + input-format: 0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + libphonenumber-js: 1.13.8 + prop-types: 15.8.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): dependencies: react: 19.2.6 @@ -7699,6 +7782,11 @@ snapshots: sisteransi@1.0.5: {} + sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + source-map-js@1.2.1: {} source-map@0.6.1: {} diff --git a/web-client/src/__tests__/AuthenticatedApp.test.tsx b/web-client/src/__tests__/AuthenticatedApp.test.tsx index b2ee1277..f46457b8 100644 --- a/web-client/src/__tests__/AuthenticatedApp.test.tsx +++ b/web-client/src/__tests__/AuthenticatedApp.test.tsx @@ -49,7 +49,6 @@ describe('AuthenticatedApp', () => { async function render() { await act(async () => { root.render() - // flush microtasks so the async init chain resolves await new Promise((r) => setTimeout(r, 0)) }) } @@ -65,10 +64,6 @@ describe('AuthenticatedApp', () => { }) } - // --------------------------------------------------------------------------- - // Happy path - // --------------------------------------------------------------------------- - it('renders the app when keycloak init resolves authenticated', async () => { keycloakMock.init.mockResolvedValue(true) @@ -129,10 +124,6 @@ describe('AuthenticatedApp', () => { expect(keycloakMock.login).not.toHaveBeenCalled() }) - // --------------------------------------------------------------------------- - // Not-authenticated path (redirect) - // --------------------------------------------------------------------------- - it('calls keycloak.login() when init resolves not-authenticated', async () => { keycloakMock.init.mockResolvedValue(false) @@ -142,10 +133,6 @@ describe('AuthenticatedApp', () => { expect(container.textContent).not.toContain('App ready') }) - // --------------------------------------------------------------------------- - // Error paths — each error type maps to the right heading - // --------------------------------------------------------------------------- - it('shows network error heading when fetch fails', async () => { keycloakMock.init.mockRejectedValue(new Error('Failed to fetch')) diff --git a/web-client/src/__tests__/NotFoundPage.test.tsx b/web-client/src/__tests__/NotFoundPage.test.tsx index 7b6f1c8f..94576fb7 100644 --- a/web-client/src/__tests__/NotFoundPage.test.tsx +++ b/web-client/src/__tests__/NotFoundPage.test.tsx @@ -1,5 +1,6 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NotFoundPage } from '@/app/pages/NotFoundPage' @@ -23,7 +24,11 @@ describe('NotFoundPage', () => { async function render() { await act(async () => { - root.render() + root.render( + + + , + ) }) } diff --git a/web-client/src/__tests__/keycloak.test.ts b/web-client/src/__tests__/keycloak.test.ts index aa38d81a..ef7298e4 100644 --- a/web-client/src/__tests__/keycloak.test.ts +++ b/web-client/src/__tests__/keycloak.test.ts @@ -7,6 +7,7 @@ const mock = { tokenParsed: { exp: Math.floor(Date.now() / 1000) + 10 } as { exp?: number } | undefined, updateToken: vi.fn<(n: number) => Promise>(), login: vi.fn<() => Promise>(), + onAuthRefreshSuccess: undefined as (() => void) | undefined, } // vi.mock is hoisted before static imports, so keycloak-js is replaced before @@ -15,9 +16,13 @@ vi.mock('keycloak-js', () => ({ default: vi.fn(() => mock), })) -const { createApiClient, resetKeycloakRefreshStateForTests } = await import('@/lib/keycloak') +const { + createApiClient, + DEFAULT_REQUEST_TIMEOUT_MS, + onTokenRefreshed, + resetKeycloakRefreshStateForTests, +} = await import('@/lib/keycloak') -/** Minimal axios adapter that captures the final request config. */ function captureAdapter(captured: { config?: InternalAxiosRequestConfig }) { return async (config: InternalAxiosRequestConfig) => { captured.config = config @@ -55,6 +60,18 @@ describe('createApiClient', () => { expect(mock.updateToken).toHaveBeenCalledWith(30) }) + it('uses the default request timeout', () => { + const client = createApiClient('/api/test') + + expect(client.defaults.timeout).toBe(DEFAULT_REQUEST_TIMEOUT_MS) + }) + + it('accepts a per-client request timeout', () => { + const client = createApiClient('/api/test', 60_000) + + expect(client.defaults.timeout).toBe(60_000) + }) + it('skips updateToken() when the token is not close to expiring', async () => { mock.tokenParsed = { exp: Math.floor(Date.now() / 1000) + 120 } const client = createApiClient('/api/test') @@ -131,3 +148,20 @@ describe('createApiClient', () => { expect(mock.login).toHaveBeenCalledTimes(1) }) }) + +describe('onTokenRefreshed', () => { + it('fans out refreshes and unsubscribes listeners independently', () => { + const firstListener = vi.fn() + const secondListener = vi.fn() + const unsubscribeFirst = onTokenRefreshed(firstListener) + const unsubscribeSecond = onTokenRefreshed(secondListener) + + mock.onAuthRefreshSuccess?.() + unsubscribeFirst() + mock.onAuthRefreshSuccess?.() + unsubscribeSecond() + + expect(firstListener).toHaveBeenCalledTimes(1) + expect(secondListener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web-client/src/__tests__/useAuth.test.ts b/web-client/src/__tests__/useAuth.test.ts index a7d78f9e..e48e46d7 100644 --- a/web-client/src/__tests__/useAuth.test.ts +++ b/web-client/src/__tests__/useAuth.test.ts @@ -1,17 +1,66 @@ -import { describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const keycloakMock = { tokenParsed: undefined as unknown, logout: vi.fn(), } +// Mirrors the real module: one listener set, fanned out on refresh. +const tokenRefreshListeners = new Set<() => void>() +let beforeTokenRefreshSubscription: (() => void) | undefined + +function emitTokenRefresh() { + for (const listener of tokenRefreshListeners) listener() +} + vi.mock('@/lib/keycloak', () => ({ default: keycloakMock, + onTokenRefreshed: (listener: () => void) => { + beforeTokenRefreshSubscription?.() + tokenRefreshListeners.add(listener) + return () => tokenRefreshListeners.delete(listener) + }, })) const { useAuth } = await import('@/features/auth/useAuth') +function renderUseAuth(container: HTMLDivElement) { + let result!: ReturnType + + function Host() { + result = useAuth() + return null + } + + const root = createRoot(container) + act(() => { + root.render(createElement(Host)) + }) + + return { result: () => result, root } +} + describe('useAuth', () => { + let container: HTMLDivElement + let root: Root | null = null + + beforeEach(() => { + container = document.createElement('div') + document.body.append(container) + }) + + afterEach(() => { + act(() => { + root?.unmount() + }) + container.remove() + tokenRefreshListeners.clear() + beforeTokenRefreshSubscription = undefined + root = null + }) + it('collapses the member_roles claim into user.role', () => { keycloakMock.tokenParsed = { name: 'Jane Coach', @@ -19,16 +68,67 @@ describe('useAuth', () => { member_roles: ['Coach', 'Admin'], } - const { user } = useAuth() + const rendered = renderUseAuth(container) + root = rendered.root - expect(user.role).toBe('admin') + expect(rendered.result().user.role).toBe('admin') }) it('defaults role to member when the claim is absent', () => { keycloakMock.tokenParsed = { name: 'Jane', email: 'jane@example.com' } - const { user } = useAuth() + const rendered = renderUseAuth(container) + root = rendered.root + + expect(rendered.result().user.role).toBe('member') + }) + + it('picks up new token claims on refresh', () => { + keycloakMock.tokenParsed = { name: 'Jane', email: 'jane@example.com' } + + const rendered = renderUseAuth(container) + root = rendered.root + + keycloakMock.tokenParsed = { name: 'Jane Renamed', email: 'jane@example.com' } + act(() => { + emitTokenRefresh() + }) + + expect(rendered.result().user.name).toBe('Jane Renamed') + }) + + it('reconciles a refresh that lands before its subscription is installed', () => { + keycloakMock.tokenParsed = { name: 'Jane', email: 'jane@example.com' } + beforeTokenRefreshSubscription = () => { + keycloakMock.tokenParsed = { name: 'Jane Renamed', email: 'jane@example.com' } + } + + const rendered = renderUseAuth(container) + root = rendered.root + + expect(rendered.result().user.name).toBe('Jane Renamed') + }) + + it('keeps refreshing the surviving consumer when another one unmounts', () => { + keycloakMock.tokenParsed = { name: 'Jane', email: 'jane@example.com' } + + const survivor = renderUseAuth(container) + root = survivor.root + + const shortLivedContainer = document.createElement('div') + document.body.append(shortLivedContainer) + const shortLived = renderUseAuth(shortLivedContainer) + + act(() => { + shortLived.root.unmount() + }) + shortLivedContainer.remove() + + keycloakMock.tokenParsed = { name: 'Jane Renamed', email: 'jane@example.com' } + act(() => { + emitTokenRefresh() + }) - expect(user.role).toBe('member') + expect(survivor.result().user.name).toBe('Jane Renamed') }) }) diff --git a/web-client/src/app/ErrorBoundary.tsx b/web-client/src/app/ErrorBoundary.tsx index e44d0da1..9d31eb06 100644 --- a/web-client/src/app/ErrorBoundary.tsx +++ b/web-client/src/app/ErrorBoundary.tsx @@ -1,5 +1,4 @@ import { Component, Fragment, type ErrorInfo, type PropsWithChildren } from 'react' -import { router } from '@/app/router/routes' import { ErrorCard } from '@/components/ui/ErrorCard' type GlobalErrorBoundaryState = { @@ -31,7 +30,7 @@ export class GlobalErrorBoundary extends Component void router.navigate('/') }, + { label: 'Go home', onClick: () => window.location.assign('/') }, { label: 'Try again', onClick: () => this.setState((s) => ({ hasError: false, resetKey: s.resetKey + 1 })), diff --git a/web-client/src/app/layout/AppShell.tsx b/web-client/src/app/layout/AppShell.tsx index 2bee0499..69df8960 100644 --- a/web-client/src/app/layout/AppShell.tsx +++ b/web-client/src/app/layout/AppShell.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react' import { NavLink, Outlet } from 'react-router-dom' import { CalendarDays, @@ -15,12 +16,12 @@ import { User, Users, } from 'lucide-react' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { useTheme } from '@/app/theme/useTheme' import type { Theme } from '@/app/theme/ThemeContext' import { NAV_ITEMS } from '@/app/navPolicy' import { useAuth } from '@/features/auth' import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Toaster } from '@/components/ui/sonner' import { DropdownMenu, DropdownMenuContent, @@ -46,8 +47,14 @@ import { useSidebar, } from '@/components/ui/sidebar' -// Icons are a presentational concern, so they're kept local to the sidebar -// rather than in the shared nav policy. +const ReactQueryDevtools = import.meta.env.DEV + ? lazy(() => + import('@tanstack/react-query-devtools').then((module) => ({ + default: module.ReactQueryDevtools, + })), + ) + : null + const NAV_ICONS: Record = { '/': LayoutDashboard, '/sport-events': CalendarDays, @@ -201,7 +208,12 @@ function AppShellContent() { - + {ReactQueryDevtools && ( + + + + )} + ) } diff --git a/web-client/src/app/pages/DashboardPage.tsx b/web-client/src/app/pages/DashboardPage.tsx index eec17359..dcdd6b00 100644 --- a/web-client/src/app/pages/DashboardPage.tsx +++ b/web-client/src/app/pages/DashboardPage.tsx @@ -4,10 +4,13 @@ import { Link } from 'react-router-dom' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { BarList, DonutChart } from '@/components/ui/chart-primitives' import { Button } from '@/components/ui/button' +import { ErrorNotice } from '@/components/ui/ErrorNotice' import { PageHeader } from '@/components/ui/page-header' import { Skeleton } from '@/components/ui/skeleton' import { StatCard } from '@/components/ui/stat-card' +import { serverErrorMessage } from '@/lib/server-error' import { + type DashboardAdminCountsSection, type DashboardDirectorSportSection, type DashboardFeedbackItem, type DashboardAdminOrganizationSection, @@ -37,12 +40,37 @@ const initials = (name: string) => .toUpperCase() export function DashboardPage() { - const { view, states } = useDashboardViewModel() - const showBalanceCard = Boolean(view.myBalance || states.myBalance?.isLoading) - const showTeamCard = Boolean(view.myTeam || states.myTeam?.isLoading) - const showSportCards = Boolean(view.mySport || states.mySport?.isLoading) - const showEventsCards = Boolean(view.myEvents || states.myEvents?.isLoading) - const showFeedbackStat = Boolean(view.myFeedback || states.myFeedback?.isLoading) + const { view, states, rootError, rootRefetch } = useDashboardViewModel() + + if (rootError) { + return ( +
+ + +
+ ) + } + + const showAdminSections = Boolean( + view.adminCounts || states.adminCounts?.isLoading || states.adminCounts?.error, + ) + const showBalanceCard = Boolean( + view.myBalance || states.myBalance?.isLoading || states.myBalance?.error, + ) + const showTeamCard = Boolean(view.myTeam || states.myTeam?.isLoading || states.myTeam?.error) + const showSportCards = Boolean( + view.mySport || states.mySport?.isLoading || states.mySport?.error, + ) + const showEventsCards = Boolean( + view.myEvents || states.myEvents?.isLoading || states.myEvents?.error, + ) + const showFeedbackStat = Boolean( + view.myFeedback || states.myFeedback?.isLoading || states.myFeedback?.error, + ) return (
@@ -52,13 +80,15 @@ export function DashboardPage() { subtitle="Here's what's happening across your club." /> - {view.adminCounts && ( + {showAdminSections && ( <> - + {view.adminCounts && ( + + )} )} @@ -88,7 +118,7 @@ function AdminCountsSection({ counts, state, }: { - counts: NonNullable['view']['adminCounts']> + counts?: DashboardAdminCountsSection state?: DashboardSectionState }) { if (state?.isLoading) { @@ -101,6 +131,12 @@ function AdminCountsSection({ ) } + if (state?.error) { + return + } + + if (!counts) return null + return (
@@ -312,6 +348,9 @@ function DirectorSportCards({
) } + if (state?.error) { + return + } if (!sport) return null return ( @@ -340,6 +379,9 @@ function TeamCard({ state?: DashboardSectionState }) { if (state?.isLoading) return + if (state?.error) { + return + } if (!team) return null return ( @@ -359,6 +401,9 @@ function BalanceCard({ state?: DashboardSectionState }) { if (state?.isLoading) return + if (state?.error) { + return + } if (!balance) return null return ( @@ -386,6 +431,9 @@ function EventsCards({ ) } + if (state?.error) { + return + } if (!events) return null return ( @@ -416,6 +464,9 @@ function FeedbackStat({ state?: DashboardSectionState }) { if (state?.isLoading) return + if (state?.error) { + return + } if (!feedback) return null return ( @@ -597,7 +648,7 @@ function sectionBody( } if (state?.error) { - return

{state.error.message}

+ return } if (isEmpty) { diff --git a/web-client/src/app/pages/NotFoundPage.tsx b/web-client/src/app/pages/NotFoundPage.tsx index d875ce47..2074534f 100644 --- a/web-client/src/app/pages/NotFoundPage.tsx +++ b/web-client/src/app/pages/NotFoundPage.tsx @@ -1,7 +1,9 @@ -import { router } from '@/app/router/routes' +import { useNavigate } from 'react-router-dom' import { Button } from '@/components/ui/button' export function NotFoundPage() { + const navigate = useNavigate() + return (
@@ -15,7 +17,7 @@ export function NotFoundPage() { -
diff --git a/web-client/src/app/pages/RouteErrorPage.test.tsx b/web-client/src/app/pages/RouteErrorPage.test.tsx new file mode 100644 index 00000000..ac3ee25a --- /dev/null +++ b/web-client/src/app/pages/RouteErrorPage.test.tsx @@ -0,0 +1,103 @@ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { + createMemoryRouter, + Outlet, + RouterProvider, + type RouteObject, +} from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RouteErrorPage } from '@/app/pages/RouteErrorPage' + +function AppShell() { + return ( +
+ + +
+ ) +} + +function CrashingPage(): never { + throw new Error('Page render failed') +} + +function createTestRouter(childRoute: RouteObject, routeError?: unknown) { + return createMemoryRouter( + [ + { + element: , + children: [ + { + id: 'page-boundary', + errorElement: , + children: [childRoute], + }, + ], + }, + ], + { + initialEntries: ['/'], + hydrationData: + routeError === undefined + ? undefined + : { + loaderData: {}, + errors: { 'page-boundary': routeError }, + }, + }, + ) +} + +describe('RouteErrorPage', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + document.body.innerHTML = '
' + container = document.getElementById('root') as HTMLDivElement + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + async function renderRouter(routes: RouteObject, routeError?: unknown) { + const router = createTestRouter(routes, routeError) + + await act(async () => { + root.render() + }) + } + + it('keeps the app shell mounted when a routed page crashes', async () => { + await renderRouter({ index: true, element: }) + + expect(container.querySelector('nav')?.textContent).toBe('App navigation') + expect(container.textContent).toContain('Something went wrong') + expect(container.textContent).toContain( + 'An unexpected error occurred while loading this page.', + ) + }) + + it('shows the status for a thrown route response', async () => { + await renderRouter( + { index: true, element:
Page content
}, + { + status: 404, + statusText: 'Not Found', + internal: false, + data: null, + }, + ) + + expect(container.querySelector('nav')?.textContent).toBe('App navigation') + expect(container.textContent).toContain('404 Not Found') + }) +}) diff --git a/web-client/src/app/pages/RouteErrorPage.tsx b/web-client/src/app/pages/RouteErrorPage.tsx new file mode 100644 index 00000000..5bdd74e8 --- /dev/null +++ b/web-client/src/app/pages/RouteErrorPage.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' +import { isRouteErrorResponse, useRouteError } from 'react-router-dom' +import { RotateCw } from 'lucide-react' +import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' + +// Route-level errorElement: catches render crashes inside the routed tree so they land +// here (inside AppShell, sidebar intact) instead of react-router's default dev overlay. +export function RouteErrorPage() { + const error = useRouteError() + + useEffect(() => { + if (import.meta.env.DEV) { + console.error('RouteErrorPage caught a routing/render error', error) + } + }, [error]) + + const description = isRouteErrorResponse(error) + ? `${error.status}${error.statusText ? ` ${error.statusText}` : ''}` + : 'An unexpected error occurred while loading this page.' + + return ( +
+
+ + Something went wrong + {description} + + + + +
+
+ ) +} diff --git a/web-client/src/app/pages/model/useDashboardViewModel.ts b/web-client/src/app/pages/model/useDashboardViewModel.ts index bb211e07..f55a11d1 100644 --- a/web-client/src/app/pages/model/useDashboardViewModel.ts +++ b/web-client/src/app/pages/model/useDashboardViewModel.ts @@ -130,6 +130,7 @@ export interface DashboardView { export interface DashboardSectionState { isLoading: boolean error: Error | null + refetch: () => void } export interface DashboardViewModel { @@ -143,9 +144,13 @@ export interface DashboardViewModel { adminCounts?: DashboardSectionState adminOrganization?: DashboardSectionState } + // Set only when the root dashboard call itself failed (not a dependent query like + // events/sports/teams) — every section state above traces back to this same failure, + // so the page renders one error instead of one per section. + rootError: Error | null + rootRefetch: () => void } -// "Recent" = latest N by created_at, no time window. const RECENT_FEEDBACK_COUNT = 3 const DIRECTOR_TEAM_PREVIEW_COUNT = 5 const ADMIN_SPORT_PREVIEW_COUNT = 5 @@ -197,7 +202,6 @@ function shouldShowBalance(role: Role): boolean { return role === 'member' } -// recent_feedback arrives already scoped to the caller; render the latest N by created_at. function buildFeedbackSection(feedback: FeedbackSummary[]): DashboardFeedbackSection { const items = feedback .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) @@ -216,6 +220,14 @@ function buildFeedbackSection(feedback: FeedbackSummary[]): DashboardFeedbackSec } } +function hasEventsContent(events: DashboardEventsSection): boolean { + return Boolean(events.nextEvent) || events.upcomingCount > 0 || events.items.length > 0 +} + +function hasFeedbackContent(feedback: DashboardFeedbackSection): boolean { + return feedback.total > 0 || feedback.items.length > 0 +} + export function buildDirectorSportSection( dashboard: DirectorDashboard, ): DashboardDirectorSportSection { @@ -333,6 +345,10 @@ export function useDashboardViewModel(): DashboardViewModel { const shouldLoadOrganizationInsights = data ? data.role === 'admin' : user.role === 'admin' const sportsQuery = useSportsList(shouldLoadOrganizationInsights) const teamsQuery = useTeamsList(shouldLoadOrganizationInsights) + const dashboardRefetch = dashboardQuery.refetch + const eventsRefetch = eventsQuery.refetch + const sportsRefetch = sportsQuery.refetch + const teamsRefetch = teamsQuery.refetch const role = user.role @@ -345,6 +361,7 @@ export function useDashboardViewModel(): DashboardViewModel { const state: DashboardSectionState = { isLoading: dashboardQuery.isLoading, error: dashboardQuery.error, + refetch: () => void dashboardRefetch(), } fillSections(view, states, data, state, { @@ -352,26 +369,44 @@ export function useDashboardViewModel(): DashboardViewModel { eventsState: { isLoading: dashboardQuery.isLoading || eventsQuery.isLoading, error: dashboardQuery.error ?? eventsQuery.error, + refetch: () => { + void dashboardRefetch() + void eventsRefetch() + }, }, sports: sportsQuery.data ?? [], teams: teamsQuery.data ?? [], organizationState: { isLoading: dashboardQuery.isLoading || sportsQuery.isLoading || teamsQuery.isLoading, error: dashboardQuery.error ?? sportsQuery.error ?? teamsQuery.error, + refetch: () => { + void dashboardRefetch() + void sportsRefetch() + void teamsRefetch() + }, }, }) - return { view, states } + return { + view, + states, + rootError: dashboardQuery.error, + rootRefetch: () => void dashboardRefetch(), + } }, [ data, + dashboardRefetch, dashboardQuery.error, dashboardQuery.isLoading, + eventsRefetch, eventsQuery.data, eventsQuery.error, eventsQuery.isLoading, + sportsRefetch, sportsQuery.data, sportsQuery.error, sportsQuery.isLoading, + teamsRefetch, teamsQuery.data, teamsQuery.error, teamsQuery.isLoading, @@ -416,33 +451,56 @@ function fillSections( } switch (data.role) { - case 'trainee': + case 'trainee': { + const events = buildEventsSection(data.upcoming_events, data.next_event, org.events) + const feedback = buildFeedbackSection(data.recent_feedback) + + // Always shown: a zero balance is the "settled up" state (status: 'clear'), not an + // empty section, so it must not be gated on the amount being non-zero. view.myBalance = buildBalanceSection(data.balance_cents) - view.myEvents = buildEventsSection(data.upcoming_events, data.next_event, org.events) - view.myFeedback = buildFeedbackSection(data.recent_feedback) + if (hasEventsContent(events)) { + view.myEvents = events + } + if (hasFeedbackContent(feedback)) { + view.myFeedback = feedback + } states.myBalance = state states.myEvents = org.eventsState states.myFeedback = state break + } + + case 'trainer': { + const events = buildEventsSection(data.upcoming_events, null, org.events) + const feedback = buildFeedbackSection(data.recent_feedback) - case 'trainer': view.myTeam = { teamName: data.team.name, totalMembers: data.total_members, } - view.myEvents = buildEventsSection(data.upcoming_events, null, org.events) - view.myFeedback = buildFeedbackSection(data.recent_feedback) + if (hasEventsContent(events)) { + view.myEvents = events + } + if (hasFeedbackContent(feedback)) { + view.myFeedback = feedback + } states.myTeam = state states.myEvents = org.eventsState states.myFeedback = state break + } + + case 'director': { + const events = buildEventsSection(data.upcoming_events, null, org.events) - case 'director': view.mySport = buildDirectorSportSection(data) - view.myEvents = buildEventsSection(data.upcoming_events, null, org.events) + if (hasEventsContent(events)) { + view.myEvents = events + } states.mySport = state states.myEvents = org.eventsState break + } case 'admin': view.adminCounts = buildAdminCounts(data) diff --git a/web-client/src/app/router/routes.tsx b/web-client/src/app/router/routes.tsx index d69167ce..c0779d39 100644 --- a/web-client/src/app/router/routes.tsx +++ b/web-client/src/app/router/routes.tsx @@ -2,67 +2,74 @@ import { createBrowserRouter } from 'react-router-dom' import { AppShell } from '@/app/layout/AppShell' import { ROUTE_ROLES } from '@/app/navPolicy' import { DashboardPage } from '@/app/pages/DashboardPage' -import { MembersPage } from '@/features/members' -import { SportEventsPage } from '@/features/sport-events' -import { PaymentsPage } from '@/features/payments' -import { LettersPage } from '@/features/letters' -import { OrganizationPage } from '@/features/organization' +import { NotFoundPage } from '@/app/pages/NotFoundPage' +import { RouteErrorPage } from '@/app/pages/RouteErrorPage' +import { RouteRoleGuard } from '@/app/router/RouteRoleGuard' import { FeedbackPage } from '@/features/feedback' import { HelperPage } from '@/features/helper' +import { LettersPage } from '@/features/letters' +import { MembersPage } from '@/features/members' +import { OrganizationPage } from '@/features/organization' +import { PaymentsPage } from '@/features/payments' import { ProfilePage } from '@/features/profile' -import { NotFoundPage } from '@/app/pages/NotFoundPage' -import { RouteRoleGuard } from '@/app/router/RouteRoleGuard' +import { SportEventsPage } from '@/features/sport-events' export const router = createBrowserRouter([ { path: '/', element: , children: [ - { index: true, element: }, - { - path: 'members', - element: ( - - - - ), - }, - { path: 'profile', element: }, - { path: 'sport-events', element: }, - { - path: 'payments', - element: ( - - - - ), - }, - { - path: 'letters', - element: ( - - - - ), - }, - { path: 'organization', element: }, - { - path: 'feedback', - element: ( - - - - ), - }, { - path: 'helper', - element: ( - - - - ), + // Keep routed page failures inside AppShell so the sidebar remains usable. + errorElement: , + children: [ + { index: true, element: }, + { + path: 'members', + element: ( + + + + ), + }, + { path: 'profile', element: }, + { path: 'sport-events', element: }, + { + path: 'payments', + element: ( + + + + ), + }, + { + path: 'letters', + element: ( + + + + ), + }, + { path: 'organization', element: }, + { + path: 'feedback', + element: ( + + + + ), + }, + { + path: 'helper', + element: ( + + + + ), + }, + { path: '*', element: }, + ], }, - { path: '*', element: }, ], }, ]) diff --git a/web-client/src/app/theme/ThemeProvider.tsx b/web-client/src/app/theme/ThemeProvider.tsx index 2678d42d..480f315c 100644 --- a/web-client/src/app/theme/ThemeProvider.tsx +++ b/web-client/src/app/theme/ThemeProvider.tsx @@ -27,7 +27,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) { localStorage.setItem(STORAGE_KEY, theme) }, [theme]) - // Listen for OS theme changes when in 'system' mode useEffect(() => { if (theme !== 'system') return diff --git a/web-client/src/components/ui/ErrorMessage.tsx b/web-client/src/components/ui/ErrorMessage.tsx deleted file mode 100644 index faf987e0..00000000 --- a/web-client/src/components/ui/ErrorMessage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { AlertCircle } from 'lucide-react' - -export function ErrorMessage({ message }: { message: string }) { - return ( -
- - {message} -
- ) -} diff --git a/web-client/src/components/ui/ErrorNotice.tsx b/web-client/src/components/ui/ErrorNotice.tsx new file mode 100644 index 00000000..4297b51e --- /dev/null +++ b/web-client/src/components/ui/ErrorNotice.tsx @@ -0,0 +1,48 @@ +import { AlertCircle, RotateCw } from 'lucide-react' + +import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface ErrorNoticeProps { + title?: string + message: string + onRetry?: () => void + retryLabel?: string + compact?: boolean + className?: string +} + +/** + * Shared inline error UI for query/detail failures. Not for render crashes (GlobalErrorBoundary) + * or auth bootstrap failures (AuthenticatedApp) — those keep using ErrorCard. + */ +export function ErrorNotice({ + title = 'Something went wrong', + message, + onRetry, + retryLabel = 'Try again', + compact = false, + className, +}: ErrorNoticeProps) { + return ( + + + {!compact && {title}} + {message} + {onRetry && ( + + + + )} + + ) +} diff --git a/web-client/src/components/ui/calendar.tsx b/web-client/src/components/ui/calendar.tsx index aa39128a..af9eeb5e 100644 --- a/web-client/src/components/ui/calendar.tsx +++ b/web-client/src/components/ui/calendar.tsx @@ -4,9 +4,16 @@ import { ChevronLeftIcon, ChevronRightIcon, } from 'lucide-react' -import { DayPicker, getDefaultClassNames } from 'react-day-picker' +import { DayPicker, type DropdownProps, getDefaultClassNames } from 'react-day-picker' import { Button, buttonVariants } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { cn } from '@/lib/utils' export type CalendarProps = React.ComponentProps @@ -32,20 +39,21 @@ function Calendar({ root: cn(defaultClassNames.root, 'w-fit'), months: cn('flex flex-col gap-4 sm:flex-row'), month: cn('space-y-3'), - nav: cn('absolute inset-x-0 top-3 flex items-center justify-between px-3'), + // The nav spans the caption row edge-to-edge; make its transparent middle click-through + // so it can't steal pointer events from the month/year dropdowns underneath it. + nav: cn('pointer-events-none absolute inset-x-0 top-3 flex items-center justify-between px-3'), button_previous: cn( buttonVariants({ variant: buttonVariant, size: 'icon-sm' }), - 'size-8 bg-transparent p-0', + 'pointer-events-auto size-8 bg-transparent p-0', ), button_next: cn( buttonVariants({ variant: buttonVariant, size: 'icon-sm' }), - 'size-8 bg-transparent p-0', + 'pointer-events-auto size-8 bg-transparent p-0', ), month_caption: cn('flex h-8 items-center justify-center px-8'), - caption_label: cn('text-body-sm font-semibold'), - dropdowns: cn('flex items-center justify-center gap-1 text-body-sm'), - dropdown: cn('absolute inset-0 opacity-0'), - dropdown_root: cn('relative inline-flex items-center gap-1'), + caption_label: cn('inline-flex items-center gap-1 text-body-sm font-semibold whitespace-nowrap'), + dropdowns: cn('flex items-center justify-center gap-1.5 text-body-sm'), + dropdown_root: cn('relative'), chevron: cn('size-4'), weekdays: cn('flex'), weekday: cn('w-9 text-center text-caption font-medium text-text-tertiary'), @@ -79,10 +87,63 @@ function Calendar({ return }, + Dropdown: CalendarDropdown, }} {...props} /> ) } +// react-day-picker renders month/year navigation as native + + {selected?.label ?? value} + + {/* The calendar usually lives inside a Popover (z-60), so the list has to outrank it. */} + + {options?.map((option) => ( + + {option.label} + + ))} + + + ) +} + export { Calendar } diff --git a/web-client/src/components/ui/data-table.tsx b/web-client/src/components/ui/data-table.tsx index 9af372e2..d7498643 100644 --- a/web-client/src/components/ui/data-table.tsx +++ b/web-client/src/components/ui/data-table.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/utils' // Shared table shell; scrolls horizontally before columns collapse too far. export function DataTable({ className, ...props }: React.ComponentProps<'table'>) { return ( -
+
) diff --git a/web-client/src/components/ui/date-picker.tsx b/web-client/src/components/ui/date-picker.tsx index a54fb5cd..49ae676b 100644 --- a/web-client/src/components/ui/date-picker.tsx +++ b/web-client/src/components/ui/date-picker.tsx @@ -8,7 +8,15 @@ import { dateToInputValue, inputValueToDate } from '@/lib/date-fields' import { formatDate } from '@/lib/format' import { cn } from '@/lib/utils' -interface DatePickerProps { +const DEFAULT_START_MONTH = new Date(1930, 0) + +interface DatePickerNavigationProps { + startMonth?: Date + endMonth?: Date + defaultMonth?: Date +} + +interface DatePickerProps extends DatePickerNavigationProps { value: string onChange: (value: string) => void id?: string @@ -27,8 +35,13 @@ export function DatePicker({ placeholder = 'Pick a date', disabled, required, + startMonth = DEFAULT_START_MONTH, + endMonth = getDefaultEndMonth(), + defaultMonth, 'aria-invalid': ariaInvalid, }: DatePickerProps) { + const selectedDate = inputValueToDate(value) + return ( @@ -53,7 +66,12 @@ export function DatePicker({ onChange(date ? dateToInputValue(date) : '')} aria-label={ariaLabel} /> @@ -62,7 +80,7 @@ export function DatePicker({ ) } -interface DateTimePickerProps { +interface DateTimePickerProps extends DatePickerNavigationProps { value: string onChange: (value: string) => void id?: string @@ -81,9 +99,13 @@ export function DateTimePicker({ placeholder = 'Pick a date and time', disabled, required, + startMonth = DEFAULT_START_MONTH, + endMonth = getDefaultEndMonth(), + defaultMonth, 'aria-invalid': ariaInvalid, }: DateTimePickerProps) { const [datePart, timePart] = splitLocalDateTime(value) + const selectedDate = inputValueToDate(datePart) const handleDateSelect = (date: Date | undefined) => { if (!date) { @@ -124,7 +146,12 @@ export function DateTimePicker({ @@ -151,6 +178,11 @@ function joinLocalDateTime(datePart: string, timePart: string): string { return `${datePart}T${timePart}` } +function getDefaultEndMonth(): Date { + const today = new Date() + return new Date(today.getFullYear() + 10, today.getMonth()) +} + function formatDateLabel(value: string, placeholder: string): string { return value ? formatDate(value) : placeholder } diff --git a/web-client/src/components/ui/dialog-form-skeleton.tsx b/web-client/src/components/ui/dialog-form-skeleton.tsx new file mode 100644 index 00000000..40e14447 --- /dev/null +++ b/web-client/src/components/ui/dialog-form-skeleton.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export function DialogFormSkeleton() { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ ) +} diff --git a/web-client/src/components/ui/dialog-stepper.tsx b/web-client/src/components/ui/dialog-stepper.tsx index df06b956..5795fb7f 100644 --- a/web-client/src/components/ui/dialog-stepper.tsx +++ b/web-client/src/components/ui/dialog-stepper.tsx @@ -2,6 +2,7 @@ import { CheckIcon } from 'lucide-react' import { Button } from '@/components/ui/button' import { DialogFooter } from '@/components/ui/dialog' +import { PendingButton } from '@/components/ui/pending-button' import { cn } from '@/lib/utils' export interface DialogStep { @@ -106,9 +107,9 @@ export function DialogStepperFooter({ > {isFirstStep ? 'Cancel' : 'Back'} - + + {isLastStep ? submitLabel : nextLabel} + ) } diff --git a/web-client/src/components/ui/dialog.tsx b/web-client/src/components/ui/dialog.tsx index 4b8584ab..761c1a27 100644 --- a/web-client/src/components/ui/dialog.tsx +++ b/web-client/src/components/ui/dialog.tsx @@ -47,11 +47,8 @@ function DialogOverlay({ ) } -// Radix Dialog locks background scroll via `react-remove-scroll`, only allowing wheel/touch -// events that land inside its own content node. A Popover portals to `document.body` by -// default, landing as a DOM *sibling* of the dialog content rather than a descendant, so its -// scroll gets swallowed. Exposing the dialog content node here lets nested popovers (e.g. -// MultiSelectCombobox) portal into it instead, keeping their scroll inside the allowed subtree. +// Radix Dialog locks background scroll via `react-remove-scroll`. Exposing the dialog +// content node lets nested primitives detect dialog context and switch to modal layering. const DialogContentContainerContext = React.createContext(null) export function useDialogContentContainer() { @@ -62,9 +59,16 @@ function DialogContent({ className, children, showCloseButton = true, + dismissOnInteractOutside = true, + onInteractOutside, ...props }: React.ComponentProps & { showCloseButton?: boolean + /** + * Set false on dialogs holding a form, so a stray outside click can't discard in-progress + * input. Closing stays available via the X button, the Cancel action, or Escape. + */ + dismissOnInteractOutside?: boolean }) { const [container, setContainer] = React.useState(null) @@ -74,6 +78,10 @@ function DialogContent({ { + onInteractOutside?.(event) + if (!dismissOnInteractOutside) event.preventDefault() + }} className={cn( "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 items-start gap-6 rounded-none bg-popover p-6 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className diff --git a/web-client/src/components/ui/dropdown-menu.tsx b/web-client/src/components/ui/dropdown-menu.tsx index 4f4f84e4..a3011ff0 100644 --- a/web-client/src/components/ui/dropdown-menu.tsx +++ b/web-client/src/components/ui/dropdown-menu.tsx @@ -42,7 +42,9 @@ function DropdownMenuContent({ sideOffset={sideOffset} align={align} className={cn( - 'roost-scroll z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover p-1.5 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', + // Horizontal padding lives on the items (px-3), not the container, so the separator + // can span the full menu width edge-to-edge instead of being clipped short on the right. + 'roost-scroll z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover py-1.5 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', className )} {...props} @@ -184,7 +186,7 @@ function DropdownMenuSeparator({ return ( ) diff --git a/web-client/src/components/ui/input.tsx b/web-client/src/components/ui/input.tsx index b959abfe..5d009c5e 100644 --- a/web-client/src/components/ui/input.tsx +++ b/web-client/src/components/ui/input.tsx @@ -2,18 +2,21 @@ import * as React from "react" import { cn } from "@/lib/utils" -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) -} +const Input = React.forwardRef>( + function Input({ className, type, ...props }, ref) { + return ( + + ) + } +) export { Input } diff --git a/web-client/src/components/ui/password-input.tsx b/web-client/src/components/ui/password-input.tsx new file mode 100644 index 00000000..05efebd6 --- /dev/null +++ b/web-client/src/components/ui/password-input.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { Eye, EyeOff } from 'lucide-react' + +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +// Password field with a show/hide toggle. Forwards every native input prop through to Input, +// so it stays a drop-in replacement for ``. +const PasswordInput = React.forwardRef< + HTMLInputElement, + Omit, 'type'> +>(function PasswordInput({ className, disabled, ...props }, ref) { + const [visible, setVisible] = React.useState(false) + const Icon = visible ? EyeOff : Eye + + return ( +
+ + +
+ ) +}) + +export { PasswordInput } diff --git a/web-client/src/components/ui/pending-button.test.tsx b/web-client/src/components/ui/pending-button.test.tsx new file mode 100644 index 00000000..20b7e8b8 --- /dev/null +++ b/web-client/src/components/ui/pending-button.test.tsx @@ -0,0 +1,52 @@ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { PendingButton } from './pending-button' + +describe('PendingButton', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + document.body.innerHTML = '
' + container = document.getElementById('root') as HTMLDivElement + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + document.body.innerHTML = '' + }) + + it('renders the label and stays enabled when not pending', async () => { + await act(async () => { + root.render( + + Save profile + , + ) + }) + + const button = container.querySelector('button') + expect(button?.disabled).toBe(false) + expect(button?.textContent).toBe('Save profile') + }) + + it('disables the button and shows the spinner and pending label when pending', async () => { + await act(async () => { + root.render( + + Save profile + , + ) + }) + + const button = container.querySelector('button') + expect(button?.disabled).toBe(true) + expect(button?.textContent).toBe('Saving…') + expect(button?.querySelector('[data-slot="spinner"]')).not.toBeNull() + }) +}) diff --git a/web-client/src/components/ui/pending-button.tsx b/web-client/src/components/ui/pending-button.tsx new file mode 100644 index 00000000..e3411a90 --- /dev/null +++ b/web-client/src/components/ui/pending-button.tsx @@ -0,0 +1,35 @@ +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { Spinner } from "@/components/ui/spinner" + +interface PendingButtonProps extends React.ComponentProps { + isPending: boolean + pendingLabel: string +} + +function PendingButton({ + isPending, + pendingLabel, + disabled, + children, + ...props +}: PendingButtonProps) { + return ( + + ) +} + +function PendingButtonContent({ pendingLabel }: { pendingLabel: string }) { + return ( + <> + + {pendingLabel} + + ) +} + +export { PendingButton, PendingButtonContent } +export type { PendingButtonProps } diff --git a/web-client/src/components/ui/phone-input.tsx b/web-client/src/components/ui/phone-input.tsx new file mode 100644 index 00000000..39423359 --- /dev/null +++ b/web-client/src/components/ui/phone-input.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import RPNInput, { type Country, type Value } from 'react-phone-number-input' +import 'react-phone-number-input/style.css' + +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +interface PhoneInputProps { + value: string + onChange: (value: string) => void + id?: string + disabled?: boolean + required?: boolean + defaultCountry?: Country + 'aria-invalid'?: boolean + 'aria-label'?: string + className?: string +} + +// Phone field with a country-flag + dial-code selector and as-you-type formatting +// (react-phone-number-input). Stores the value in E.164 (e.g. +491701234567), which the +// member service accepts. The national number is rendered through our themed Input so the +// field matches every other text field; the country column is styled in index.css. +function PhoneInput({ + value, + onChange, + disabled, + defaultCountry = 'DE', + className, + ...props +}: PhoneInputProps) { + return ( + onChange(next ?? '')} + inputComponent={PhoneNumberField} + className={cn('roost-phone-input flex items-center gap-2', className)} + numberInputProps={{ 'aria-invalid': props['aria-invalid'] }} + {...props} + /> + ) +} + +// react-phone-number-input passes the national-number input props here; forward them to Input. +const PhoneNumberField = React.forwardRef< + HTMLInputElement, + React.ComponentProps +>(function PhoneNumberField(props, ref) { + return +}) + +export { PhoneInput } diff --git a/web-client/src/components/ui/popover.tsx b/web-client/src/components/ui/popover.tsx index 8f3eba43..1761014a 100644 --- a/web-client/src/components/ui/popover.tsx +++ b/web-client/src/components/ui/popover.tsx @@ -4,8 +4,19 @@ import { Popover as PopoverPrimitive } from 'radix-ui' import { useDialogContentContainer } from '@/components/ui/dialog' import { cn } from '@/lib/utils' -function Popover({ ...props }: React.ComponentProps) { - return +function Popover({ + modal, + ...props +}: React.ComponentProps) { + const dialogContainer = useDialogContentContainer() + + return ( + + ) } function PopoverTrigger({ @@ -23,18 +34,14 @@ function PopoverContent({ }: React.ComponentProps & { container?: HTMLElement | null }) { - // Defaults to the nearest DialogContent so Radix Dialog's scroll-lock (which only allows - // wheel/touch events inside its own content node) doesn't swallow scrolling in this popover. - const dialogContainer = useDialogContentContainer() - return ( - + , + error: , + warning: , + info: , + }} + toastOptions={{ + unstyled: true, + classNames: { + toast: + 'group relative flex min-h-16 w-[calc(100vw-1.5rem)] max-w-[25rem] items-center gap-3 overflow-hidden border border-border/90 bg-popover/95 py-3 pl-3 pr-4 text-text-primary shadow-[0_14px_40px_-18px_oklch(0.14_0.01_286/0.45)] backdrop-blur-md ' + + 'before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-border before:content-[""]', + icon: 'flex size-9 shrink-0 items-center justify-center bg-surface-sunken text-text-secondary [&>svg]:size-4.5', + content: 'flex min-w-0 flex-1 flex-col justify-center gap-0.5', + title: 'text-body-sm font-semibold leading-5 tracking-[-0.015em]', + description: 'text-caption leading-4 text-text-secondary', + success: 'before:bg-primary [&_[data-icon]]:bg-primary/12 [&_[data-icon]]:text-[oklch(0.48_0.16_130.85)] dark:[&_[data-icon]]:text-primary', + error: + 'before:bg-destructive [&_[data-icon]]:bg-destructive/10 [&_[data-icon]]:text-destructive', + // No warning token in the theme; use the same amber the StatCard "positive/negative" + // tones use inline, so warnings still read as amber without an unbacked utility. + warning: + 'before:bg-[oklch(0.75_0.15_75)] [&_[data-icon]]:bg-[oklch(0.75_0.15_75/0.12)] [&_[data-icon]]:text-[oklch(0.55_0.14_75)] dark:[&_[data-icon]]:text-[oklch(0.8_0.14_75)]', + info: 'before:bg-accent-foreground [&_[data-icon]]:bg-accent [&_[data-icon]]:text-accent-foreground', + actionButton: + 'shrink-0 border border-primary bg-primary px-3 py-1.5 text-caption font-semibold text-primary-foreground transition-opacity hover:opacity-90', + cancelButton: + 'shrink-0 border border-border bg-surface-sunken px-3 py-1.5 text-caption font-semibold text-text-secondary transition-colors hover:text-text-primary', + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/web-client/src/components/ui/spinner.tsx b/web-client/src/components/ui/spinner.tsx new file mode 100644 index 00000000..554d9098 --- /dev/null +++ b/web-client/src/components/ui/spinner.tsx @@ -0,0 +1,18 @@ +import { Loader2Icon } from "lucide-react" + +import { cn } from "@/lib/utils" + +// The spinner is only ever shown beside a visible label (e.g. "Saving…") that already announces +// state, so it's decorative here — hiding it from a11y avoids a second, competing status region. +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( +