Skip to content

FormRenderer, answer validation, and testing router validation - #10

Open
ayselomer wants to merge 3 commits into
mainfrom
feat/form-renderer
Open

ayselomer wants to merge 3 commits into
mainfrom
feat/form-renderer

Conversation

@ayselomer

@ayselomer ayselomer commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Overview

Built out the dynamic form rendering and validation foundation

Key Changes

  • packages/shared:
    • Implemented core validation and conditional evaluation utilities (validateAnswers, isSectionVisible) covering required constraints, email formatting, select option verification, word/character limits, and conditional dependencies.
    • Added full test coverage for the validator suite under packages/shared/src/forms/validator.test.ts (10/10 tests passing).
    • Exported validator utilities and associated types via the shared package entry point.
  • apps/web:
    • Created FormRenderer.tsx with support for short_text, long_text, email, and select inputs.
    • Set up accessible fieldsets/labels, bypassed native HTML5 constraint tooltips with noValidate, and wired dynamic state to clear field errors on active input.
    • Handled unsupported input types with an inline fallback warning.
    • Built a sandbox page at /dev/forms rendering the track selection logic (Project vs. Education vs. Both) to manually exercise conditional section rendering.

Verification

  • Automated Checks:
    • bun test passed cleanly across all monorepo workspaces (10/10 tests).
    • Pre-commit Lefthook hooks (formatting, lint-shared, lint-web) and pre-push typecheck (tsc --noEmit across @internal-tool/web, @internal-tool/api, and @internal-tool/shared) passed with zero errors.
  • Manual Verification (/dev/forms):
    • Verified showIf logic toggles project and education sections dynamically based on track dropdown selection.
    • Verified unsupported field type (rating) renders the fallback UI.
    • Confirmed required fields and email regex display appropriate validation states.
    • Verified valid form submission surfaces the structured JSON payload without triggering page reloads.

Closes #7

@AskingRouge AskingRouge mentioned this pull request Oct 21, 2026
2 of 3 tasks

@VidurShah VidurShah left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks Aysel, this is a solid first version. The validator logic is easy to follow, showIf toggles right away, and labels are properly tied to inputs. A few things to fix before this merges, plus some smaller ones. Details are inline.

Must fix

  1. Import from @internal-tool/shared instead of relative paths into packages/shared/src (FormRenderer and the dev page). The workspace package is the boundary between apps and shared code.
  2. Answers from hidden sections are still submitted. Pick Project, fill in the project question, then switch to Education: project_experience is still in the payload. The validator skips it, but once this submits to the API it will send answers the applicant can't see. Drop answers from hidden sections on submit, and add a test for it (a pure helper in packages/shared would be easy to test).
  3. Track values should be lowercase project / education / both. They're promoted into the application_track enum (TDD 3.1), and the existing definition test already uses lowercase.
  4. /dev/forms is dev-only per the ticket, but it ships in the production build. Call notFound() when process.env.NODE_ENV === "production".

Should fix

  • hasValue treats any non-string as answered, so a required multi_select passes with []. That type isn't supported yet, but it will be soon, so it's worth handling now.
  • When an answer breaks both maxLength and maxWords, the second error overwrites the first. Either stop after the first error (like the required check does) or decide which one wins on purpose.
  • Accessibility: add aria-required (or required, since you have noValidate) to required inputs, and aria-invalid + aria-describedby pointing at the error <p>.
  • Nit: the existing shared tests are in packages/shared/tests/, so please move validator.test.ts there. The PR description also says 10/10 tests, but the file has 7.

Push fixes to this branch and re-request review when it's ready.

"use client";

import React, { useState } from "react";
import type { FormDefinition, FormField } from "../../../../packages/shared/src/forms/definition";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Import from @internal-tool/shared (it already exports the definition types, and this PR adds the validator to the index) rather than reaching into packages/shared/src by relative path.

Comment thread apps/web/src/app/dev/forms/page.tsx Outdated
@@ -0,0 +1,85 @@
import type { FormDefinition } from "../../../../../../packages/shared/src/forms/definition";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same here: import type { FormDefinition } from "@internal-tool/shared";

Comment thread apps/web/src/app/dev/forms/page.tsx Outdated
type: "select",
label: "Which track are you applying for?",
required: true,
options: ["Project", "Education", "Both"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These are promoted into the application_track enum (project | education | both, TDD 3.1), so the option values need to be lowercase. Update the showIf.in arrays below to match. If we want capitalized labels, that's a later label/value change to the definition schema.

},
],
};
export default function DevFormsPage() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The ticket says dev-only. Something like if (process.env.NODE_ENV === "production") notFound(); (from next/navigation) keeps it out of the deployed app.

setSubmittedPayload(null);
} else {
setErrors({});
setSubmittedPayload(answers);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

answers still has values from sections that are now hidden (switch Project → Education after typing in the project field). Filter to visible sections' fields before setting the payload and calling onSubmitSuccess, since that's what will go to the API.

Comment thread packages/shared/src/forms/validator.ts Outdated
}
for (const field of section.fields) {
const value = answers[field.id];
const hasValue =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Any non-string counts as answered here, so a required multi_select passes with []. Treat empty arrays as missing too.

Comment thread packages/shared/src/forms/validator.ts Outdated
if (field.maxWords !== undefined) {
const words = countWords(stringValue);
if (words > field.maxWords) {
errors[field.id] = `Cannot exceed ${field.maxWords} words.`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If both maxLength and maxWords fail, this overwrites the maxLength message. A continue after each error, or an early return per field, would keep the first one.

: "border-gray-300 focus:border-blue-600 focus:ring-1 focus:ring-blue-600";
if (field.type === "short_text" || field.type === "email") {
return (
<input

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

For screen readers: add aria-required={field.required}, plus aria-invalid={hasError} and aria-describedby pointing at an id on the error <p>. Same for the textarea and select.

@ayselomer

Copy link
Copy Markdown
Collaborator Author

Pushed the updates for all the review points!

  • Cleaned up imports in FormRenderer.tsx and /dev/forms/page.tsx so everything pulls directly from @internal-tool/shared.
  • Added filterVisibleAnswers in packages/shared so hidden section inputs get dropped from the payload on submit (plus a unit test for it).
  • Changed track options to lowercase (project, education, both) to match the enum.
  • Blocked /dev/forms in production with notFound().
  • Added accessibility attributes (required, aria-required, aria-invalid, and aria-describedby hooked up to the error message IDs).
  • Handled empty arrays in hasValue so empty multi-selects fail required checks, and stopped after maxLength so errors don't clash with maxWords.
  • Moved validator.test.ts into packages/shared/tests/. All 9 tests passing!

Keep main's shared index (header comment, ./enums, ./errors) and export ./forms/validator.
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.

Form renderer, first version

2 participants