feat(frontend): PDF upload component with mock backend (M6.6) - #85
Conversation
Scene 1 onboarding entry point: a dropzone-driven PdfUpload that
validates, previews via pdfjs, uploads with progress + abort, and
redirects to the onboarding session. Backend M3.2 endpoint not shipped
yet — wired against a local mock whose signature mirrors the real POST
for a trivial swap when the backend lands.
Added:
- `features/onboarding/PdfUpload.tsx` — dropzone (drag + click),
ext/size validation (PDF ≤ 50 MB), first-page pdfjs preview on a
canvas, upload progress bar with Cancel (AbortController), success
redirect, error state with Try-again. States: idle / previewing /
uploading / success / error. ARIA wired (dropzone `role="button"`,
`role="progressbar"`, `role="alert"`, `aria-controls`, etc.).
- `pages/OnboardingPage.tsx` — route page. Minimal header (AriaMark +
ThemeToggle + "Skip to control room"), cell selector pills, mounts
PdfUpload, shows a session stub on `/onboarding/:session_id`
(multi-step wizard lands in M8.6).
- `lib/mockUpload.ts` — `mockUploadPdf({ cellId, file, onProgress?,
signal? }): Promise<EquipmentKbOut>`. Simulates streamed progress
over ~2.5s, honors AbortSignal, rejects with a readable message
when `file.name` contains "fail" (to exercise the error path).
- `lib/kb.types.ts` — front-end mirror of `backend/modules/kb/
schemas.py::EquipmentKbOut` / `EquipmentKB`. To be kept in sync at
the real-backend swap.
Modified:
- `app/routes.tsx` — added `/onboarding` and `/onboarding/:session_id`
under `RequireAuth`, outside `AppShell` (Option A — linear flow,
sidebar/topbar would be noise during enrolment).
Scope OUT per spec — the multi-step KB builder wizard lands in M8.6.
Zero new deps (pdfjs-dist, framer-motion, react-router-dom, lucide-
react all preinstalled). All DS v2 tokens, icons via `Icons.*`
wrapper, sentence-case, no bracketed/mono-caps.
Quality gates all green:
- typecheck ✓
- build ✓ (1069 kB / 327 kB gz main; pdf.worker stays a 1.24 MB side
chunk fetched lazily on first preview)
- check (Biome) ✓
- test ✓ (9/9, unchanged)
Swap path to real M3.2: replace the mock call in
`PdfUpload.tsx::startUpload` with an `XMLHttpRequest`-based multipart
POST to `/api/v1/kb/equipment/{cellId}/upload` (XHR is required to
keep the progress callback — `fetch` still lacks upload progress).
State machine and UI stay untouched.
Closes #39
There was a problem hiding this comment.
Pull request overview
Adds a new authenticated onboarding entry flow in the frontend that lets users pick a target cell, drop/browse a PDF, preview its first page via pdf.js, and “upload” it through a mock transport before redirecting to an onboarding session stub.
Changes:
- Introduces
PdfUploadcomponent with drag/drop + picker, PDF validation, pdf.js preview rendering, progress + abort, and error/success UI states. - Adds onboarding routes (
/onboardingand/onboarding/:session_id) outsideAppShellunderRequireAuth, plus a minimalOnboardingPagewrapper and session stub. - Adds a mock upload transport (
mockUploadPdf) and frontend DTO types (kb.types.ts) to mirror backend responses.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/pages/OnboardingPage.tsx | New onboarding page UI (header + cell selector + upload mount) and session stub route view. |
| frontend/src/lib/mockUpload.ts | Mock upload implementation with progress simulation, abort support, and deterministic failure path. |
| frontend/src/lib/kb.types.ts | Frontend DTO interfaces for EquipmentKbOut / structured KB blob used by the mock and future real upload. |
| frontend/src/features/onboarding/PdfUpload.tsx | New PDF dropzone/preview/upload component implementing the onboarding “Scene 1” shell flow. |
| frontend/src/app/routes.tsx | Registers onboarding routes under RequireAuth, outside the AppShell layout. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| useEffect(() => { | ||
| if (!file || !canvasRef.current) return; | ||
| setPreviewReady(false); | ||
| let cancelled = false; | ||
| renderFirstPage(file, canvasRef.current) | ||
| .then(() => { | ||
| if (!cancelled) setPreviewReady(true); | ||
| }) | ||
| .catch(() => { | ||
| if (!cancelled) { | ||
| setError("Unable to render the PDF preview. The file may be corrupt."); | ||
| setStage("error"); | ||
| } | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [file]); |
There was a problem hiding this comment.
The PDF preview render isn’t actually cancelled when file changes: the cleanup only flips cancelled, but renderFirstPage() can still finish later and draw into the same canvas. If a user picks/drops a second file quickly, an older render can overwrite the canvas after the newer preview is shown. Consider canceling the pdf.js loading/render task in cleanup (e.g., keep a ref to the loading/render task and call destroy()/cancel()), or add a render token check inside renderFirstPage before drawing to the canvas.
| const startUpload = async () => { | ||
| if (!file) return; | ||
| setStage("uploading"); | ||
| setProgress(0); | ||
| setError(null); | ||
| const controller = new AbortController(); | ||
| abortRef.current = controller; | ||
| try { | ||
| const result = await mockUploadPdf({ | ||
| cellId, | ||
| file, | ||
| onProgress: setProgress, | ||
| signal: controller.signal, | ||
| }); | ||
| if (controller.signal.aborted) return; | ||
| setStage("success"); | ||
| onUploaded(result); | ||
| } catch (err) { | ||
| if (err instanceof UploadAbortError) { | ||
| setStage("previewing"); | ||
| return; | ||
| } | ||
| const msg = err instanceof Error ? err.message : "Upload failed."; | ||
| setError(msg); | ||
| setStage("error"); | ||
| } finally { | ||
| abortRef.current = null; | ||
| } |
There was a problem hiding this comment.
startUpload can still call setStage/setError after the component unmounts (e.g., user clicks “Skip to control room” while an upload is in flight). The unmount effect aborts the controller, which rejects the promise and runs this catch/finally, leading to state updates on an unmounted component. Add an isMounted/active flag (or similar) and bail out before setting state in then/catch/finally when unmounted.
| /** | ||
| * Types pour equipment_kb — miroir exact des DTOs Pydantic backend | ||
| * (`backend/modules/kb/schemas.py` + `kb_schema.py`). | ||
| * | ||
| * Partagés par `mockUpload` (M6.6) et, plus tard, par le vrai call | ||
| * `POST /api/v1/kb/equipment/{cell_id}/upload` une fois M3.2 shipped. | ||
| */ |
There was a problem hiding this comment.
The header comment claims these TS types are an “exact mirror” of the backend DTOs, but the shapes currently diverge (e.g., backend EquipmentKB includes kb_meta and has non-optional defaulted sections; backend EquipmentMeta includes cell_id, etc.). Either update the interfaces to match the backend schema more closely, or soften the comment to avoid implying strict parity.
Summary
Scene 1 onboarding entry point — a PDF dropzone that validates, previews via pdfjs, uploads with progress and abort, and redirects to the onboarding session stub. Backend M3.2 not shipped yet — implemented against a local mock whose signature mirrors the real POST for a trivial swap.
Closes #39.
What's added
features/onboarding/PdfUpload.tsx— dropzone (drag + click), ext/size validation (PDF ≤ 50 MB), first-page pdfjs preview on canvas, upload progress bar with Cancel (AbortController), success redirect, error state with Try-again. States:idle / previewing / uploading / success / error. Full ARIA (dropzonerole="button",role="progressbar",role="alert",aria-controls).pages/OnboardingPage.tsx— route page. Minimal header (AriaMark + ThemeToggle + "Skip to control room"), cell selector pills (P-02 / pump / other), mounts<PdfUpload />. Session stub on/onboarding/:session_id(multi-step wizard lands in M8.6).lib/mockUpload.ts—mockUploadPdf({ cellId, file, onProgress?, signal? }): Promise<EquipmentKbOut>. Simulates streamed progress over ~2.5s, honoursAbortSignal, rejects with a readable message whenfile.namecontains "fail" (to exercise the error path).lib/kb.types.ts— frontend mirror ofbackend/modules/kb/schemas.py::EquipmentKbOut/EquipmentKB. To keep in sync at the real-backend swap.Route integration — Option A (standalone)
Routes
/onboardingand/onboarding/:session_idlive outsideAppShell, underRequireAuth. Rationale: onboarding is a linear flow, the topbar/drawer of the shell would be noise during enrolment. Minimal header supplies AriaMark + ThemeToggle + a "Skip to control room" escape.Scope OUT (per spec)
Multi-step KB builder wizard ships in M8.6.
Acceptance (#39)
/onboarding/:session_idSwap path to real M3.2
Replace the
mockUploadPdfcall inPdfUpload.tsx::startUploadwith anXMLHttpRequest-based multipartPOST /api/v1/kb/equipment/{cellId}/upload(XHR required for upload progress,fetchstill lacks it in browsers). The state machine, preview, progress UI, and response typing stay untouched.Test plan — how to test on your PC
Scenarios :
--ds-accent-soft, border →--ds-accent, preview renders first page.jpgpreviewingstate, progress reset"fail"→ upload/onboarding/{uuid}stubGates (all green):
npm run typechecknpm run build(1069 kB / 327 kB gz; pdf.worker stays a 1.24 MB side chunk fetched lazily on first preview)npm run check(Biome)npm run test(9/9, unchanged)