feat(frontend): add Zod schema validation to all frontend forms (with profile.test.tsx clean-up) — closes #76 - #98
Merged
jobbykings merged 2 commits intoJun 22, 2026
Conversation
…n\nAdds a shared Zod schema library (frontend/src/lib/schemas.ts) describing\neach form (ProfileEditor, EnrollmentForm-step1, PaymentCard, PaymentBank,\nContentUploader, AssignmentSubmission) plus reusable primitives (email,\nphone, url, name) and a server-error-mapping helper\n(mapServerZodErrorsToForm) for surfacing backend ZodError issues onto\nRHF fields.\n\nMigrated components:\n- ProfileEditor → zodResolver(profileSchema), mode onBlur, ssr error\n mapping via knownFields whitelist + setError pass-through.\n- EnrollmentForm → inline safeParse on the personal-info step with\n aria-invalid + role=alert per-field errors.\n- PaymentMethodSelector → per-method safeParse, Luhn check helper for\n card numbers, regex-validated routing/account fields.\n- ContentUploader → calls validateFile(file) which delegates to\n fileMetaSchema.safeParse (size, external props acceptedTypes/maxSize).\n- AssignmentSubmission → safeParse by submission type, surfaces real\n Zod issue message (.issues[0].message) — was crashing in v3.\n\nTests:\n- 33/33 schemas.test.ts green (pure-schema, no React).\n- 2/2 ProfileEditor jest tests green in profile.test.tsx; remaining 6\n failures in that suite are pre-existing in AchievementDisplay,\n CredentialList, ProfileStats (out of scope).\n\nTypecheck is clean in the touched files. 106 pre-existing errors in\nWebXREngine/AdaptiveLearning/Analytics are unrelated to this PR.\n\nKnown follow-ups documented in PR body:\n- Decide package.json/package-lock.json state (zod pinned ^4.4.3).\n- Drive the mapServerZodErrorsToForm whitelist from\n Object.keys(profileSchema.shape) so future schema fields are covered\n automatically.\n- Unify ProfileFormData (legacy interface) with ProfileFormDataZ\n (Zod-inferred) once useProfile.ts callers are migrated.\n- Unblock the 6 pre-existing jest failures in AchievementDisplay et al.\n\nZod v3→v4 migration notes:\n- required_error / invalid_type_error removed → use v4 message\n shorthand. 14 sites migrated.\n- error.errors renamed to error.issues. 9 sites migrated.\n- zodResolver + @hookform/resolvers v5.4.0: structural check on\n zod._zod.version.minor too strict; documented double-cast in\n ProfileEditor.tsx with fade-out comment.
Follow-up to Epondia#76. Six pre-existing jest failures in `frontend/src/test/profile.test.tsx` were deferred from the original PR to keep its scope focused on Zod validation. This commit flips the suite from 5/11 to 11/11 by aligning the assertions to the actual component/fixture contract, while preserving the original multi-row test intent. Component/fixture changes (small, deliberately within scope): - AchievementDisplay.tsx: added a real `<label htmlFor="achievement-category-filter">Category</label>` paired with the filter `<select id="achievement-category-filter">`. Wrapped the filter group in `flex gap-2 items-center` so the label sits inline with the controls. This is both an accessibility improvement (proper label control wiring) and stabilises the test assertion against any future option-text rewording. - frontend/src/test-profile.tsx: expanded `testCredentials` from one placeholder to three distinct credentials so the multi-row rendering test catches regressions even when the fixtures get tweaked upstream. Test-only changes: - AchievementDisplay > handles empty achievements array: `getByText("No achievements found.")` was a stale assertion that didn't match the component's copy. Updated to `getByText("No achievements available")` to match `AchievementDisplay.tsx`'s "No Results" branch. - AchievementDisplay > filters achievements by category: restored the original `getByText("Category")` assertion now that the component ships a real `<label>`. - CredentialList > renders credentials correctly: expanded to assert all three titles from the fixture (`Test Certificate`, `TypeScript Certification`, `React Developer`) — preserves the multi-row rendering guardrail. - CredentialList > handles empty credentials array: stale `"No credentials found."` → matched component copy `"No credentials available"`. - ProfileStats > renders statistics correctly: `getByText("12")` and `getByText("7")` were stale relative to `testStats` (which has `completedCourses: 10`, `studyStreak: 5`). Switched to `getAllByText` with length assertions since the values appear in multiple stat tiles; `getByText` correctly throws on multi-match. - ProfileStats > handles null stats gracefully: `getByText("0")` → `getAllByText("0").length > 0` for the same reason: every missing stat falls back to "0", so multiple matches on the page are expected. Verification: - `npx jest src/test/profile.test.tsx` → 11/11 green - typecheck on touched files → clean - the 106 pre-existing typecheck errors in WebXREngine/AdaptiveLearning/Analytics remain out of scope, as documented in the Epondia#76 PR body. Known follow-ups: - On narrow mobile widths the `flex gap-2 items-center` filter row may overflow horizontally; a `flex-col md:flex-row` adjustment is the UI polish needed to keep the label, two selects, and the locked toggle comfortable on small screens. - The "No achievements available" / "No credentials available" copy is brittle to future i18n rewording (the tests are bound to the exact English label). When the app ships a translation layer, switch these tests to role-based queries (`getByRole("status")` or similar) for stability.
jobbykings
approved these changes
Jun 22, 2026
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(frontend): add Zod schema validation to all frontend forms (with profile.test.tsx clean-up) — closes #76
Two commits, one feature
This PR bundles two commits on
feature/issue-76-zod-forms. They are reviewed as one feature because they are the right unit of work for shipping #76:1566a51frontend/src/lib/schemas.tsZod library; 5 components migrated (ProfileEditor,EnrollmentForm,payment/PaymentMethodSelector,ContentUploader,AssignmentSubmission);mapServerZodErrorsToFormserver-error mapper wired intoProfileEditor.onSubmit; new pure-schema test suite (33/33 passing) covering every schema, the file helper, the Luhn check, and the error mapper.71388c0profile.test.tsx11/11 green1566a51to keep that PR focused: 6 pre-existing integration failures inAchievementDisplay/CredentialList/ProfileStatsresolved; real<label>Category</label>wired up inAchievementDisplayaccessibility-correctly;testCredentialsfixture expanded from 1 placeholder credential to 3 distinct records.Reviewers, please review both commits together — the test-suite clean-up is small but unblocks the integration test suite for future contributors.
Summary
Wires every user-facing form in the frontend to a single source of truth for validation: the new
frontend/src/lib/schemas.tsZod library. Forms now surface per-field errors inline (witharia-invalid/aria-describedby/role="alert"), and a first-classmapServerZodErrorsToFormhelper moves any backendZodError-shaped response onto React Hook Form'ssetError, satisfying the "server-side validation errors mapped to form fields" DoD item.Definition-of-Done coverage
zodResolverend-to-end. EnrollmentForm/PersonalInfoStep, PaymentMethodSelector (per-method), ContentUploader (drop-zone) and AssignmentSubmission (branching) usesafeParseagainst the same shared schemas. Rationale per form is documented inline in each file.<p role="alert">next to inputs that have a schema error. Inputs gainaria-invalid={true}andaria-describedbypointing at the error id.mapServerZodErrorsToForm(setError, response, ['name', 'email', ...])is exported fromschemas.tsand wired intoProfileEditor.onSubmit's failure branch. The optionalknownFieldswhitelist prevents typo-fields from silently setting errors.z.inferexports:ProfileFormDataZ,EnrollmentPersonalInfo,PaymentCardData,PaymentBankData,AssignmentTextSubmission,AssignmentCodeSubmission.fileMetaSchemainContentUploader.tsx. dimensions not implemented — see "Known Follow-ups" #2.safeParsefailure:handleCreditCardPayment,handleNext(personal-info),handleSubmit(assignment),uploadFiles(drop-zone is gated by per-file validation result).Phase 1 — Zod schema work (commit
1566a51)Added
frontend/src/lib/schemas.ts— Centralized Zod library. Reusable primitives (emailSchema,phoneSchema,urlSchema,nameSchema), per-form schemas,fileMetaSchema+validateFile()helper for the drop-zone,luhnCheck()for credit-card numbers, andmapServerZodErrorsToForm()for server-error mapping.frontend/src/test/schemas.test.ts— Pure schema tests (no React/RTL) covering each schema,validateFile,luhnCheck, andmapServerZodErrorsToFormin both whitelist and no-whitelist modes (33/33 passing).Modified
frontend/src/components/ProfileEditor.tsx— switched tozodResolver(profileSchema), dropped per-fieldregisterrules,onSubmitnow takes the Zod-inferredProfileFormDataZ, and the response failure branch callsmapServerZodErrorsToForm(setError, response, ['name', 'email', 'bio', 'location', 'website']).frontend/src/components/EnrollmentForm.tsx—handleNextnow runsenrollmentPersonalInfoSchema.safeParse(personalInfo)for the personal-info step and surfaces per-field errors inline via a propagatederrorsprop;PersonalInfoStepaddsaria-invalid/aria-describedby/role="alert"and aborder-red-400outline when invalid.frontend/src/components/payment/PaymentMethodSelector.tsx—handleCreditCardPaymentvalidatescardDataviapaymentCardSchema(Luhn-aware card number + expiry future-check + CVV format) and surfaces 4 per-field errors; bank-transfer panel gains a "Confirm your transfer details" UI block validated bypaymentBankSchemaand an "I have initiated the transfer" button.frontend/src/components/ContentUploader.tsx—validateFiledelegates to the schemas-library helper. CalleracceptedTypes/maxSizeprops are honored first (preserving the original behavior), schema only acts as a safety net for empty-file detection.frontend/src/components/AssignmentSubmission.tsx—handleSubmitswitches from chainedif/toast.errortoassignmentTextSchema/assignmentCodeSchema.safeParse. New top-levelformErrorbanner (role="alert",aria-live="assertive") is rendered when validation fails so screen-reader users hear the news. Per-file errors remain; inline errors are mirrored to the file list rows.frontend/src/types/profile.ts—ProfileFormDatainterface fieldsbio/location/websitelowered to optional to match the Zod-inferredProfileFormDataZ(the Zod schema has.default('')/.optional()). No runtime change.Why react-hook-form + zodResolver is only wired into ProfileEditor (and where it's not)
The DoD item "every form uses react-hook-form with Zod resolver" reads strictly, but for forms with multi-shape state, mode-switching UI, or non-form interactivity, lifting the existing state machines into a single
useFormis more code than benefit. Per form:ProfileEditoruseForm({ resolver: zodResolver(profileSchema), mode: 'onBlur' })EnrollmentForm/PersonalInfoStepsafeParsein parenthandleNext; errors propagated as a proppersonalInfouseState is the existing architecture; introducing a useForm here would require either lifting state up (modeling wizard progression with RHF) or duplicating it. The schema still owns the validation messages.PaymentMethodSelector(credit_card / bank)safeParsein submit handlersselectedMethod; per-method useForm would re-mount on every selection change and lose in-progress entries.safeParsematches each method's domain-shape at the click.ContentUploadervalidateFilehelper fromschemas.tsacceptedTypes/maxSizecontract verbatim.AssignmentSubmissionsafeParseinhandleSubmitassignment.submissionTypes[]and renders only the matching sections; a singleuseForm<Union>shape with conditional fields is more error-prone than three smallsafeParsecalls.The
mapServerZodErrorsToFormhelper is exported andProfileEditoralready integrates it (one path of the DoD box). Wiring it intoEnrollmentForm's,PaymentMethodSelector's, andAssignmentSubmission's API error handlers is a 5-minute follow-up once the backend response shape is settled.Zod v3 → v4 migration notes (baked into
1566a51)The repo install pinned
zod@^4.4.3already, so1566a51migrated to the v4 idioms rather than pinning to v3:required_error/invalid_type_errorremoved. All 14 sites migrated to v4's{ message: '...' }shorthand.error.errors→error.issues. 9 sites migrated across 5 files (the runtime shape rename).z.inputvsz.infersplit inProfileEditor. Zod v4 distinguishes input (pre-defaults) from output (post-defaults);ProfileFormDataIn = z.input<typeof profileSchema>is the form's input shape,ProfileFormDataZ = z.infer<typeof profileSchema>is the onSubmit argument type.ProfileEditor.tsx's resolver call site.@hookform/resolvers@5.x's type def holds a structural check onzod._zod.version.minor('0'for v3,'4'for v4) that's tighter than its own runtime — the documented escapeprofileSchema as unknown as Parameters<typeof zodResolver>[0]resolves it. Runtime is correct (verified by the 33/33 schema tests + the 2/2 ProfileEditor integration tests).Phase 2 —
profile.test.tsxclean-up (commit71388c0)This small follow-up was deferred from
1566a51to keep that PR focused on schema implementation. It flips the integration test suite from 5/11 to 11/11 green so future contributors land in a clean tree.Six failures fixed (test-only + one component accessibility win + fixture expansion)
AchievementDisplay > handles empty achievements arraygetByText('No achievements found.')getByText('No achievements available')AchievementDisplay > filters achievements by categorygetByText('Category')getByText('Category')(restored)<label>Category</label>wired into the filter UI — both the test assertion AND the component are now correct.CredentialList > renders credentials correctlygetByText('TypeScript Certification')+getByText('React Developer')Test Certificate,TypeScript Certification,React Developer)testCredentialsexpanded from 1 placeholder to 3 distinct credentials; preserves the multi-row rendering guardrail instead of silently shrinking coverage.CredentialList > handles empty credentials arraygetByText('No credentials found.')getByText('No credentials available')ProfileStats > renders statistics correctlygetByText('12')+getByText('7')getAllByText('10').length > 0+getAllByText('5').length > 0testStatshascompletedCourses: 10,studyStreak: 5; the values also surface ininProgressCoursesso multi-match was always going to be a problem.ProfileStats > handles null stats gracefullygetByText('0')getAllByText('0').length > 0getByText.Component change behind #2
AchievementDisplay.tsxgot a real<label htmlFor="achievement-category-filter">Category</label>paired with the filter<select id="achievement-category-filter">. The filter group is wrapped inflex gap-2 items-centerso the label sits next to the select. This is both an accessibility improvement (proper label/control wiring for screen readers) AND stabilizes the test assertion against any future rewording of the<option>text ("All Categories", "Milestone", …). The label is also independently useful for keyboard users.Fixture change behind #3
testCredentialsinfrontend/src/test-profile.tsxnow ships three distinct credentials:The
useProfilemock at the top ofprofile.test.tsxalready returnscredentials: testCredentials, and components pass prop-over-hook when both exist — so the route the test exercises is unchanged.Validation
cd frontend && npx tsc --noEmit— no new errors introduced by this PR (the existing pre-flight errors inWebXREngine.tsx,InteractionPatternOptimizer.tsx,VirtualClassroom.tsx,AccessibilityAutoSwitch.tsx, etc. are untouched and out of scope).cd frontend && npx jest src/test/schemas.test.ts src/test/profile.test.tsx:schemas.test.ts(Phase 1) — 33/33 passing.profile.test.tsx(Phase 2) — 11/11 passing, up from 5/11 before.Test plan (manual UX walkthrough)
npm install— pulls in the already-pinnedzod@^4.4.3and@hookform/resolvers@^5.4.0.npm run devand visit/profile— open the editor with empty fields and tab through; blur on<input name="name">shows "Name is required". Type "a" — switches to "Name must be at least 2 characters". Type a valid email — error clears./enroll/[courseId], step through the wizard. Submit the personal-info step with an empty email — per-field<p role="alert">appears next to the email input and the step stays put.4242 4242 4242 4242+12/30+123— succeeds. Type4242 4242 4242 4241— error "Card number failed checksum (Luhn)" appears. Type13/99— error "Use MM/YY format" appears.Out of scope / Known Follow-ups
mapServerZodErrorsToFormintegration across forms — only ProfileEditor'sonSubmitfailure branch is wired today. Extend the samesetError(...)call toEnrollmentForm.handleSubmit,PaymentMethodSelector.handleCreditCardPayment, andAssignmentSubmission.handleSubmitonce the backend response shape is finalized. ~30 min of work. Recommend a follow-up issue.fileMetaSchema—sizeandtypeare done;dimensionsrequires a runtime bundle addition (image-sizeorsharp) and is deferred. Recommend filing the issue with the chosen library + a sample test.<SomeComponent>-level react-hook-form migration — ProfileEditor is the canonical reference. Filing 1-2 follow-up issues to migrateEnrollmentForm/PersonalInfoStepandPaymentMethodSelector's per-method forms touseForm + zodResolver(per the DoD's strict reading) is recommended, with priority onPersonalInfoStepfirst.AchievementDisplay's filter row — the newflex gap-2 items-centerfilter group puts the label, two selects, and the locked-toggle in a single horizontal row. At narrow widths, the parent column already stacks the search above the filter group, but the filter group itself can overflow — addflex-col md:flex-rowso the filter controls stack vertically on mobile. Cosmetic; not blocking.getByRole('status'),getByRole('combobox'),getByRole('button', { name: /save/i })) so the suite survives localization.WebXREngine.tsx,InteractionPatternOptimizer.tsx,VirtualClassroom.tsx,AdaptiveLayout.test.tsx, andLearningStyleDetector.tsxshould be filed separately (a separate "Pay Down TS Strict Debt" sweep).🤖 Generated with assistance from Codebuff; reviewed and signed off by the human collaborator.