Skip to content

feat(frontend): PDF upload component with mock backend (M6.6) - #85

Merged
vgtray merged 1 commit into
mainfrom
feat/39-pdf-upload
Apr 22, 2026
Merged

feat(frontend): PDF upload component with mock backend (M6.6)#85
vgtray merged 1 commit into
mainfrom
feat/39-pdf-upload

Conversation

@vgtray

@vgtray vgtray commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

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 (dropzone role="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.tsmockUploadPdf({ cellId, file, onProgress?, signal? }): Promise<EquipmentKbOut>. Simulates streamed progress over ~2.5s, honours AbortSignal, rejects with a readable message when file.name contains "fail" (to exercise the error path).
  • lib/kb.types.ts — frontend mirror of backend/modules/kb/schemas.py::EquipmentKbOut / EquipmentKB. To keep in sync at the real-backend swap.

Route integration — Option A (standalone)

Routes /onboarding and /onboarding/:session_id live outside AppShell, under RequireAuth. 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)

  • Drop PDF → preview → POST (mock) → redirect to /onboarding/:session_id
  • Clear rejection of non-PDF and oversize files
  • Spinner + abort during upload

Swap path to real M3.2

Replace the mockUploadPdf call in PdfUpload.tsx::startUpload with an XMLHttpRequest-based multipart POST /api/v1/kb/equipment/{cellId}/upload (XHR required for upload progress, fetch still lacks it in browsers). The state machine, preview, progress UI, and response typing stay untouched.

Test plan — how to test on your PC

git fetch
git checkout feat/39-pdf-upload
git pull
make up  # Docker — sinon proxy 500 hors container
# → http://localhost:5173 → login
# → navigate to http://localhost:5173/onboarding

Scenarios :

Action Expected
Drag a real PDF onto dropzone bg → --ds-accent-soft, border → --ds-accent, preview renders first page
Click "browse" OS file picker opens
Drop a .jpg Red error alert "Only PDF files up to 50 MB"
Drop a PDF > 50 MB Same error alert
Click "Upload" Progress bar fills 0→100% over ~2.5s with tabular-nums counter
Click "Cancel" during upload Returns to previewing state, progress reset
Rename a PDF to contain "fail" → upload Mock rejects with error message + "Try again" button
Successful upload After 100% + ~650ms → navigates to /onboarding/{uuid} stub
Switch ThemeToggle dark ↔ light All surfaces follow
Keyboard : Tab → dropzone → Enter/Space Opens file picker

Gates (all green):

  • npm run typecheck
  • npm 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)

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
Copilot AI review requested due to automatic review settings April 22, 2026 22:32
@vgtray
vgtray merged commit 924bee8 into main Apr 22, 2026
11 checks passed
@vgtray
vgtray deleted the feat/39-pdf-upload branch April 22, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PdfUpload component with drag/drop + picker, PDF validation, pdf.js preview rendering, progress + abort, and error/success UI states.
  • Adds onboarding routes (/onboarding and /onboarding/:session_id) outside AppShell under RequireAuth, plus a minimal OnboardingPage wrapper 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.

Comment on lines +97 to +114
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]);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +191
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;
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +7
/**
* 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.
*/

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M6.6 — PDF upload component (shell)

2 participants