M2-T06: scaffold React SPA frontend with typed OpenAPI client
- Vite + React 18 + TypeScript + Mantine + TanStack Query + react-router-dom - typed client: openapi-typescript -> src/api/schema.d.ts (committed), openapi-fetch - fetch wrapper middleware: cookies, X-CSRF-Token on writes, 401 -> /login, non-401 errors carry parsed JSON body - SessionProvider/useSession (GET /api/session), ProtectedRoute skeleton - app shell (Mantine + router) with placeholder login/home/config pages + gear nav - dev proxy to FastAPI; vitest smoke test; frontend README - npm scripts: dev/build/preview/lint/typecheck/test/codegen
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
# Home Automation — Frontend
|
||||
|
||||
React SPA for the home-automation backend. Built with Vite + React 18 + TypeScript.
|
||||
Scaffolded in M2-T06; feature pages filled in by T07–T10.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Library | Version |
|
||||
|---|---|---|
|
||||
| Build | Vite | 6.x |
|
||||
| UI framework | React | 18.x |
|
||||
| Language | TypeScript | 5.x |
|
||||
| Component library | Mantine | 7.x |
|
||||
| Data fetching | TanStack Query | 5.x |
|
||||
| Routing | react-router-dom | 6.x |
|
||||
| API client codegen | openapi-typescript | 7.x |
|
||||
| API client runtime | openapi-fetch | 0.17.x |
|
||||
| Testing | Vitest + @testing-library/react | 4.x / 14.x |
|
||||
|
||||
## npm Scripts
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | Start Vite dev server (with backend proxy — see below) |
|
||||
| `npm run build` | `tsc -b && vite build` — type-check then build to `dist/` |
|
||||
| `npm run preview` | Serve the built `dist/` locally |
|
||||
| `npm run lint` | ESLint (flat config, React + TypeScript rules) |
|
||||
| `npm run typecheck` | `tsc --noEmit` — type-check without emitting files |
|
||||
| `npm run test` | Vitest (run once, no watch) |
|
||||
| `npm run codegen` | Regenerate `src/api/schema.d.ts` from `../openapi/openapi.json` |
|
||||
|
||||
All frontend gates must pass before any task is considered done:
|
||||
```bash
|
||||
npm run codegen
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build # must produce dist/
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── index.html Vite entry HTML
|
||||
├── vite.config.ts Vite + Vitest config; dev proxy
|
||||
├── tsconfig.json References tsconfig.app.json + tsconfig.node.json
|
||||
├── tsconfig.app.json App source TS config (strict, react-jsx)
|
||||
├── tsconfig.node.json Vite config TS config
|
||||
├── eslint.config.js Flat ESLint config (React + TypeScript rules)
|
||||
├── package.json Dependencies + npm scripts
|
||||
├── package-lock.json Lockfile (committed; CI uses npm ci)
|
||||
└── src/
|
||||
├── main.tsx Entry point; mounts <App> into #root
|
||||
├── App.tsx Provider stack + route tree (MantineProvider → QueryClient → Router → SessionProvider)
|
||||
├── vite-env.d.ts /// <reference types="vite/client" /> for CSS imports
|
||||
├── test-setup.ts Vitest global setup (@testing-library/jest-dom)
|
||||
├── api/
|
||||
│ ├── schema.d.ts AUTO-GENERATED from openapi/openapi.json (committed)
|
||||
│ ├── client.ts openapi-fetch client + CSRF/cookie/401 middleware
|
||||
│ └── csrf.ts Module-level CSRF token holder (setCsrfToken / getCsrfToken)
|
||||
├── auth/
|
||||
│ ├── SessionProvider.tsx TanStack Query against GET /api/session; exposes useSession()
|
||||
│ └── ProtectedRoute.tsx Redirects to /login when unauthenticated
|
||||
└── pages/
|
||||
├── LoginPage.tsx Placeholder → T07 builds the real form
|
||||
├── HomePage.tsx Placeholder → T09 builds the map/heatmap view
|
||||
└── ConfigPage.tsx Placeholder → T08 builds the config editor
|
||||
```
|
||||
|
||||
## Dev Proxy (local development)
|
||||
|
||||
`npm run dev` starts Vite on port 5173. The Vite config proxies API/auth paths
|
||||
to the FastAPI backend running on port 8000:
|
||||
|
||||
| Proxied path | Backend URL |
|
||||
|---|---|
|
||||
| `/api/*` | `http://localhost:8000` |
|
||||
| `/login` | `http://localhost:8000` |
|
||||
| `/logout` | `http://localhost:8000` |
|
||||
| `/static/*` | `http://localhost:8000` |
|
||||
| `/docs` | `http://localhost:8000` |
|
||||
| `/openapi.json` | `http://localhost:8000` |
|
||||
|
||||
To develop locally:
|
||||
1. Start the backend: `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000`
|
||||
2. Start the frontend: `cd frontend && npm run dev`
|
||||
3. Open `http://localhost:5173` — the app proxies all API calls to the backend.
|
||||
|
||||
Since the dev server proxies the session cookie path, auth flows work exactly as
|
||||
they would in the deployed (same-origin) setup.
|
||||
|
||||
## Adding a New Page + Typed Query
|
||||
|
||||
This is the pattern every task T07–T10 follows to wire up a real page:
|
||||
|
||||
### 1. Run codegen (if the OpenAPI contract changed)
|
||||
|
||||
```bash
|
||||
npm run codegen
|
||||
```
|
||||
|
||||
The generated `src/api/schema.d.ts` is committed to the repo. CI enforces that
|
||||
the file is in sync with `openapi/openapi.json` via:
|
||||
```bash
|
||||
npm run codegen && git diff --exit-code frontend/src/api/schema.d.ts
|
||||
```
|
||||
|
||||
### 2. Import the typed client
|
||||
|
||||
```typescript
|
||||
// src/pages/SomePage.tsx
|
||||
import apiClient from '../api/client'
|
||||
```
|
||||
|
||||
### 3. Write a typed TanStack Query
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
function usePooRecords(limit = 100) {
|
||||
return useQuery({
|
||||
queryKey: ['poo', { limit }],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/poo', { params: { query: { limit } } })
|
||||
// res.data is typed as PooResponse | undefined
|
||||
// On non-2xx the middleware throws ApiError; TanStack Query catches it.
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The `params.query` and `params.path` objects are fully typed from `schema.d.ts`.
|
||||
TypeScript will error if you pass unknown query params or mistype a path param.
|
||||
|
||||
### 4. Write a typed mutation (write request)
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
function useDeletePoo() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (timestamp: string) =>
|
||||
apiClient.DELETE('/api/poo/{timestamp}', {
|
||||
params: { path: { timestamp } },
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The middleware (`src/api/client.ts`) automatically injects the `X-CSRF-Token` header
|
||||
on all non-GET/HEAD requests (sourced from `getCsrfToken()`). You do not need to
|
||||
handle CSRF manually in page code.
|
||||
|
||||
### 5. Add the route in App.tsx
|
||||
|
||||
```typescript
|
||||
// App.tsx
|
||||
import { SomePage } from './pages/SomePage'
|
||||
|
||||
// Inside <Routes>:
|
||||
<Route path="/some-path" element={<SomePage />} />
|
||||
// or, if protected:
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/some-path" element={<SomePage />} />
|
||||
</Route>
|
||||
```
|
||||
|
||||
## OpenAPI codegen + CI sync rule
|
||||
|
||||
`src/api/schema.d.ts` is committed to the repository (not gitignored).
|
||||
|
||||
**Rule**: whenever `openapi/openapi.json` changes (any backend task that modifies
|
||||
a route or schema), CI must run:
|
||||
```bash
|
||||
cd frontend && npm run codegen
|
||||
git diff --exit-code frontend/src/api/schema.d.ts
|
||||
```
|
||||
If the file has changed but the new version was not committed, CI fails.
|
||||
|
||||
To update manually after a backend change:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run codegen
|
||||
git add src/api/schema.d.ts
|
||||
git commit -m "M2-Txx: update generated OpenAPI types"
|
||||
```
|
||||
|
||||
## Production Build
|
||||
|
||||
The production build (`npm run build`) writes static files to `frontend/dist/`.
|
||||
In the deployed setup (M2-T11 onwards), FastAPI serves `dist/` as a static
|
||||
directory and falls back to `dist/index.html` for all non-`/api` paths,
|
||||
enabling client-side routing with deep links.
|
||||
|
||||
The multi-stage Dockerfile (M2-T12) builds the frontend in a Node container and
|
||||
copies only `dist/` into the Python image — the production image does not
|
||||
contain Node or npm.
|
||||
Reference in New Issue
Block a user