Search docs... ⌘K Search Documentation Search across all documentation pages
Filetree Example
Complete file-by-file breakdown of the Cloud Architect Profile Form. Every file needed for a working multi-step form inside Next.js App Router with React 19 server actions, BDD-aligned validation, upload protection, pending UX, and Playwright-ready test hooks.
cloud-architect-profile/
├── features/ ← BDD Spec
│ ├── profile-form/
│ │ ├── 01-purpose.feature
│ │ ├── 02-field-types.feature
│ │ ├── 03-multi-step.feature
│ │ ├── 04-validation.feature
│ │ ├── 05-submission.feature
│ │ ├── 06-ui-ux.feature
│ │ ├── 07-accessibility.feature
│ │ ├── 08-file-uploads.feature
│ │ ├── 09-type-safety.feature
│ │ └── 10-tech-stack.feature
│ └── step-definitions/
│ ├── form-steps.ts
│ └── upload-steps.ts
├── lib/
│ ├── schemas/
│ │ └── architect-profile.ts ← Validation Rules (shared zod)
│ └── actions/
│ └── create-profile.ts ← Submission Logic (server action)
├── stores/
│ └── profile-form-store.ts ← Step Logic (zustand)
├── components/
│ └── profile-form/
│ ├── profile-form-provider.tsx ← Form UI (react-hook-form root)
│ ├── profile-form-wizard.tsx ← Stepper Control (step router)
│ ├── step-indicator.tsx ← Stepper Control (progress bar)
│ ├── step-navigation.tsx ← Stepper Control (Back/Next)
│ ├── step-announcer.tsx ← Accessibility (screen reader)
│ ├── auto-focus-step.tsx ← Accessibility (focus management)
│ ├── accessible-field.tsx ← Form UI (reusable field)
│ ├── personal-info-step.tsx ← Form UI (step 1)
│ ├── experience-step.tsx ← Form UI (step 2)
│ ├── job-history-step.tsx ← Form UI (step 3, dynamic fields)
│ ├── skills-step.tsx ← Form UI (step 4)
│ ├── uploads-step.tsx ← File Upload (step 5 layout)
│ ├── file-upload.tsx ← File Upload (drag/drop/preview)
│ └── submit-step.tsx ← Submission Logic (review + useActionState)
├── app/
│ └── profile/
│ └── create/
│ └── page.tsx ← Page entry point
└── e2e/
└── profile-form.spec.ts ← Playwright E2E tests
Responsibility File(s) Why it lives there BDD Spec features/**/*.featureGherkin files stakeholders read and approve before coding Validation Rules lib/schemas/architect-profile.tsShared zod schema -- imported by both client (zodResolver) and server (safeParse) Form UI components/profile-form/*-step.tsxOne Client Component per step, each uses useFormContext Step Logic stores/profile-form-store.tsZustand store tracks current step, completed steps, navigation rules Stepper Control step-indicator.tsx, step-navigation.tsxUI chrome for the wizard -- progress bar and Back/Next buttons Submission Logic lib/actions/create-profile.ts + submit-step.tsxServer action handles data, submit-step bridges react-hook-form to useActionState File Upload file-upload.tsx + server action file validationClient: drag/drop + preview + size/type check. Server: duplicate MIME validation Playwright Tests e2e/profile-form.spec.tsE2E tests that map directly to Gherkin scenarios
The Gherkin files are the source of truth. Every validation rule, UI behavior, and error message originates here. Developers implement code to satisfy these scenarios.
# features/profile-form/04-validation.feature
Feature : Profile Form Validation
Background :
Given the architect is logged in
And they navigate to "/profile/create"
Scenario : Required fields show errors when empty
Given the architect is on step 1 "Personal Info"
And they have not filled in any fields
When they click "Next"
Then they should see
Key code points:
Background runs before every scenario — sets up a logged-in user on the create page
Each Scenario maps to exactly one test case — the title describes the expected behavior
Given / When / Then steps read like plain English so non-developers can review acceptance criteria
Error message strings (e.g., "Full name is required") must match the zod schema messages exactly
The .refine() scenario (end date after start date) shows cross-field validation in Gherkin form
File upload scenarios test both size limits and MIME type rejection as separate cases
The single source of truth for all validation. Every zod error message matches a Gherkin Then they should see "..." exactly. Imported by the client (zodResolver) and the server action (safeParse).
// lib/schemas/architect-profile.ts
import { z } from "zod" ;
// ── Shared constants ───────────────────────────────────
const ACCEPTED_IMAGE_TYPES = [ "image/jpeg" , "image/png" , "image/webp" ];
const MAX_FILE_SIZE = 5 * 1024 * 1024 ; // 5MB
const MAX_DIAGRAM_COUNT
Key code points:
imageFileSchema is a reusable zod refinement — validates both size (5MB) and MIME type, shared by all upload fields
Each step has its own exported schema (personalInfoSchema, experienceSchema, etc.) so trigger() can validate one step at a time
.refine() on jobEntrySchema handles cross-field validation (end date > start date) with a custom path targeting the specific field
.or(z.literal("")) on linkedinUrl allows the field to be left empty while still validating format when filled
STEP_SCHEMAS maps step numbers to their schema — used by StepNavigation to validate only the current step's fields
architectProfileSchema merges all step schemas into one — used by zodResolver (client) and safeParse (server) for full-form validation
z.infer<typeof ...> generates TypeScript types from each schema — single source of truth for both validation and types
Zustand store owns the step state machine. Knows which step is active, which are completed, and whether navigation is allowed. Decoupled from form data (react-hook-form owns that).
// stores/profile-form-store.ts
import { create } from "zustand" ;
export type StepStatus = "upcoming" | "current" | "completed" ;
export const STEPS = [
{ id: 1 , label: "Personal Info" },
{ id: 2 , label:
Key code points:
create<ProfileFormState>() defines a typed zustand store — state + actions in one object
completedSteps uses a Set<number> for O(1) lookup of whether a step is done
goToNext() clones the Set before mutating (new Set(state.completedSteps)) — zustand requires immutable updates
goToPrevious() does not mark the current step as completed — going back is non-destructive
canNavigateTo() enforces linear wizard flow: you can revisit completed steps or advance one step ahead, but not skip
getStepStatus() returns "current" | "completed" | "upcoming" — drives the visual state of StepIndicator
reset() clears all progress — used after successful submission or when the user wants to start over
The store is decoupled from form data — zustand owns navigation state, react-hook-form owns field values
Server action receives FormData, validates server-side with the same zod schema, validates uploaded files independently, persists data, and redirects. The two-argument signature (prevState, formData) is required by useActionState.
// lib/actions/create-profile.ts
"use server" ;
import { z } from "zod" ;
import { redirect } from "next/navigation" ;
import { architectProfileSchema } from "@/lib/schemas/architect-profile" ;
// Server-only file schema -- stricter, checks actual MIME
const serverFileSchema = z
. instanceof (File)
Key code points:
"use server" marks this as a server action — it runs on the server, never shipped to the client bundle
serverFileSchema duplicates file validation server-side — never trust client-only checks (users can bypass the browser)
(prevState, formData) two-argument signature is required by useActionState — prevState carries the previous return value
ProfileActionState return type has fieldErrors: Record<string, string> — the submit step maps these back to react-hook-form and navigates to the correct step
.omit({ profilePhoto: true, ... }) strips file fields from the text schema — files are validated separately since safeParse can't handle File objects from FormData the same way
formData.getAll("certifications") retrieves multiple values for the same form field name — used for array fields (certifications, platforms, specialties)
redirect() throws internally in Next.js — the catch block must re-throw NEXT_REDIRECT or the redirect silently fails
Steps 1-6 are numbered comments — the action follows a strict pipeline: parse → validate text → validate files → check duplicates → upload → create record → redirect
Creates the single useForm instance shared across all steps. The zodResolver connects zod validation to react-hook-form. mode: "onBlur" gives real-time feedback when the user tabs away from a field.
// components/profile-form/profile-form-provider.tsx
"use client" ;
import { useForm, FormProvider } from "react-hook-form" ;
import { zodResolver } from "@hookform/resolvers/zod" ;
import {
architectProfileSchema,
type ArchitectProfile,
} from "@/lib/schemas/architect-profile" ;
const defaultValues : Partial
Key code points:
useForm<ArchitectProfile>() creates a single form instance typed to the full schema — shared across all steps via context
zodResolver(architectProfileSchema) connects zod validation to react-hook-form — validation errors auto-populate formState.errors
mode: "onBlur" validates fields when the user tabs away — gives real-time feedback without interrupting typing
FormProvider wraps all children — any nested component can call useFormContext() to access form state without prop drilling
defaultValues seeds the form with empty/zero values — prevents uncontrolled-to-controlled input warnings
Routes to the correct step component based on zustand state. Wraps the active step in accessibility helpers (screen reader announcer and auto-focus).
// components/profile-form/profile-form-wizard.tsx
"use client" ;
import { useProfileFormStore } from "@/stores/profile-form-store" ;
import { StepIndicator } from "./step-indicator" ;
import { StepNavigation } from "./step-navigation" ;
import { StepAnnouncer } from "./step-announcer" ;
import { AutoFocusStep } from "./auto-focus-step"
Key code points:
STEP_COMPONENTS is a Record<number, React.ComponentType> lookup — maps step number to the component to render
useProfileFormStore() reads currentStep from zustand — the wizard re-renders when the step changes
StepIndicator + StepAnnouncer + AutoFocusStep + StepNavigation wrap the active step — separation of concerns between progress UI, accessibility, and navigation
data-testid="profile-form-wizard" provides a Playwright hook for the wizard container
The progress bar. Shows checkmarks for completed steps, highlights the current step, and disables future steps. Each step button has ARIA labels for screen readers.
// components/profile-form/step-indicator.tsx
"use client" ;
import { cn } from "@/lib/utils" ;
import { Check } from "lucide-react" ;
import { STEPS, useProfileFormStore } from "@/stores/profile-form-store" ;
export function StepIndicator () {
const { currentStep , getStepStatus
Key code points:
<nav aria-label="Form progress"> makes the step bar a landmark for screen readers
<p className="sr-only"> announces "Step X of Y" — visible only to assistive technology
aria-current="step" on the active button tells screen readers which step the user is on
aria-label on each button includes the step number, label, and completion status (e.g., "Step 1: Personal Info (completed)")
data-status={status} attribute enables both Playwright assertions (toHaveAttribute) and CSS styling
canNavigateTo(step.id) disables forward steps — prevents skipping ahead in the wizard
Completed steps show a <Check> icon with aria-hidden="true" — decorative, the aria-label already conveys status
Connector lines (h-px w-8) between steps change color based on completion — visual progress indicator
Back/Next/Submit buttons. "Next" validates the current step's fields via trigger() before advancing. The step-to-field mapping ensures only the active step's fields are checked.
// components/profile-form/step-navigation.tsx
"use client" ;
import { useFormContext } from "react-hook-form" ;
import { Button } from "@/components/ui/button" ;
import { useProfileFormStore, TOTAL_STEPS } from "@/stores/profile-form-store" ;
import type { ArchitectProfile } from "@/lib/schemas/architect-profile" ;
const STEP_FIELDS
Key code points:
STEP_FIELDS maps each step number to its field names — trigger(fields) validates only the current step, not the entire form
handleNext is async because trigger() returns a Promise<boolean> — it runs zod validation on just the listed fields
goToNext() only fires if validation passes — the form cannot advance with errors
On the last step, the button changes from type="button" (Next) to type="submit" (Submit Profile) — triggers the server action
<div /> placeholder when isFirstStep keeps the flex layout aligned — Back button space is preserved
Reusable field wrapper that wires up aria-required, aria-invalid, aria-describedby, and role="alert" error messages. Used by every step component.
// components/profile-form/accessible-field.tsx
"use client" ;
import { useFormContext } from "react-hook-form" ;
import { Label } from "@/components/ui/label" ;
import { Input } from "@/components/ui/input" ;
import type { ArchitectProfile } from "@/lib/schemas/architect-profile" ;
import type { HTMLInputTypeAttribute }
Key code points:
useFormContext<ArchitectProfile>() accesses the shared form — no props needed, the provider handles it
register(name) connects the input to react-hook-form — handles onChange, onBlur, ref, and value
aria-required={required} tells screen readers the field is mandatory
aria-invalid={!!error} flips to true when validation fails — screen readers announce "invalid entry"
aria-describedby={error ? errorId : undefined} links the input to its error message — screen readers read the error when the field is focused
role="alert" on the error <p> makes it a live region — screen readers announce the error immediately when it appears
data-testid={field-$} provides consistent Playwright selectors across all fields
Uses AccessibleField for each input. Matches the Gherkin: "step 1 should render with name, email, phone, LinkedIn fields."
// components/profile-form/personal-info-step.tsx
"use client" ;
import { AccessibleField } from "./accessible-field" ;
export function PersonalInfoStep () {
return (
< fieldset data-testid = "step-personal-info" >
< legend className = "text-lg font-semibold mb-4" >Personal Information</ legend
Key code points:
<fieldset> with <legend> groups related fields semantically — screen readers announce "Personal Information" when entering the group
AccessibleField handles all ARIA wiring — each step component stays simple and declarative
data-testid="step-personal-info" enables Playwright to assert which step is visible
The required prop on AccessibleField adds both aria-required and the visual asterisk (*)
Number input for years, text input for role, checkbox group for certifications, and a textarea with character counter for bio.
// components/profile-form/experience-step.tsx
"use client" ;
import { useFormContext } from "react-hook-form" ;
import { AccessibleField } from "./accessible-field" ;
import { Label } from "@/components/ui/label" ;
import { Textarea } from "@/components/ui/textarea" ;
import { Checkbox } from "@/components/ui/checkbox"
Key code points:
watch("certifications") subscribes to the array value — re-renders the component when certifications change
setValue("certifications", updated, { shouldValidate: true }) writes the toggled array back and triggers validation
CERTIFICATIONS is a const array — keeps options centralized, easy to add/remove
Textarea with aria-describedby conditionally points to either bio-error or bio-counter — screen readers get the right context
Character counter changes color to text-destructive when over 1000 — visual warning before validation fires
{certifications.length} selected shows a count below checkboxes — quick feedback without requiring the user to count
Dynamic field array with useFieldArray. Matches Gherkin: "supports repeatable entries", "Add Another Position", and "Remove" behavior.
// components/profile-form/job-history-step.tsx
"use client" ;
import { useFormContext, useFieldArray } from "react-hook-form" ;
import { Input } from "@/components/ui/input" ;
import { Label } from "@/components/ui/label" ;
import { Button } from "@/components/ui/button" ;
import { Textarea } from "@/components/ui/textarea"
Key code points:
useFieldArray({ control, name: "jobs" }) manages the dynamic array — provides fields, append, and remove
field.id (not index) is used as the React key — stable across additions and removals, prevents input value ghosting
MAX_JOBS = 10 caps the array — the "Add Another Position" button hides when the limit is reached
fields.length > 1 guards the Remove button — you can't remove the last entry (schema requires at least one)
watch(jobs.$.isCurrent) disables the end date field when "I currently work here" is checked
errors.jobs?.[index]?.endDate uses optional chaining for nested field array errors — the error path includes the array index
errors.jobs?.root displays the array-level error ("Add at least one position") — separate from per-entry errors
append({...}) adds a new blank entry with all required fields initialized — prevents uncontrolled input warnings
Cloud platform checkboxes, specialty multi-select, and radio buttons for proficiency per platform.
// components/profile-form/skills-step.tsx
"use client" ;
import { useFormContext } from "react-hook-form" ;
import { Label } from "@/components/ui/label" ;
import { Checkbox } from "@/components/ui/checkbox" ;
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" ;
import type { ArchitectProfile } from
Key code points:
togglePlatform / toggleSpecialty manually manage array state via setValue with { shouldValidate: true } — checkboxes don't use register() because they map to arrays, not individual values
watch("cloudPlatforms") dynamically renders proficiency radio groups — only shows AWS/Azure/GCP proficiency when the platform is selected
`${platform}Proficiency` as keyof ArchitectProfile computes the field name dynamically — e.g., selecting "aws" renders the awsProficiency radio group
RadioGroup with onValueChange syncs the selected level back to the form via setValue
PLATFORMS uses as const — TypeScript narrows the value to the literal union "aws" | "azure" | "gcp" matching the zod enum
Drag-and-drop with preview thumbnails, client-side type/size validation, and a remove button per file. Uses useController to sync files into react-hook-form state.
// components/profile-form/file-upload.tsx
"use client" ;
import { useCallback, useState, useRef } from "react" ;
import { useFormContext, useController } from "react-hook-form" ;
import { Button } from "@/components/ui/button" ;
import { X, Upload } from "lucide-react" ;
import { cn } from "@/lib/utils"
Key code points:
useController({ control, name }) syncs file state with react-hook-form — unlike register(), it works with non-native inputs (drag/drop zones)
validateFile runs client-side checks (MIME type, size, max count) before adding files — fast feedback without a server round-trip
URL.createObjectURL(file) creates preview thumbnails — each URL is tracked in previews state for cleanup
URL.revokeObjectURL(p.url) in removeFile prevents memory leaks — every created URL must be revoked when no longer needed
isDragging state toggles border/background styling on drag-over — visual cue that the drop zone is active
isMaxReached disables the drop zone and file input — prevents exceeding the maxFiles limit
role="button" + tabIndex={0} + onKeyDown makes the drop zone keyboard-accessible — Enter/Space triggers the file picker
The hidden <input type="file"> with className="sr-only" is the actual file input — the visible drop zone delegates clicks to it via inputRef
Single vs. multiple mode: field.onChange(validFiles[0]) replaces for single, [...files, ...validFiles] appends for multiple
Composes three FileUpload instances: single photo, multiple diagrams, multiple screenshots.
// components/profile-form/uploads-step.tsx
"use client" ;
import { FileUpload } from "./file-upload" ;
export function UploadsStep () {
return (
< fieldset data-testid = "step-uploads" >
< legend className = "text-lg font-semibold mb-4" >Upload Files</ legend
Key code points:
Composes three FileUpload instances with different configs — single (profile photo) vs. multiple (diagrams, screenshots)
maxFiles={10} matches the zod schema's MAX_DIAGRAM_COUNT — client and server limits stay in sync
Bridges react-hook-form values to the server action via FormData. Uses useActionState for pending/error state. Maps server field errors back to the correct step.
// components/profile-form/submit-step.tsx
"use client" ;
import { useActionState, useEffect } from "react" ;
import { useFormContext } from "react-hook-form" ;
import { Button } from "@/components/ui/button" ;
import { Loader2 } from "lucide-react" ;
import {
createProfile,
type
Key code points:
useActionState(createProfile, initialState) returns [state, formAction, isPending] — bridges the server action to React's pending UI
FIELD_TO_STEP maps field names to step numbers — when the server returns a field error, the UI navigates the user back to the correct step
The first useEffect iterates state.fieldErrors and calls setError() for each — maps server validation errors back into react-hook-form so they display inline
goToStep(targetStep) auto-navigates to the step containing the first error — the user doesn't have to manually find which step failed
handleSubmit manually builds FormData from getValues() — bridges react-hook-form's state to the server action's expected FormData input
formData.append() (not set) is used for array fields — certifications, platforms, specialties, and multiple file uploads
JSON.stringify(values.jobs) serializes the job array — complex nested objects can't be sent as individual FormData entries
isPending disables the submit button and shows a Loader2 spinner — prevents double submission
ReviewCard component renders a summary card per step with an "Edit" button — lets the user jump back to any step before submitting
Invisible live region that announces step changes to screen readers.
// components/profile-form/step-announcer.tsx
"use client" ;
import { useEffect, useRef } from "react" ;
import { STEPS, useProfileFormStore } from "@/stores/profile-form-store" ;
export function StepAnnouncer () {
const { currentStep } = useProfileFormStore ();
const announcerRef =
Key code points:
role="status" + aria-live="polite" creates a live region — screen readers announce content changes without interrupting the current reading
aria-atomic="true" ensures the entire announcement is read, not just the changed text
className="sr-only" hides the div visually — it exists only for assistive technology
previousStep ref prevents re-announcing when the component re-renders without a step change
announcerRef.current.textContent = ... updates the text imperatively — React state changes would cause unnecessary re-renders
Moves focus to the first input when the step changes. Ensures keyboard users land in the right place.
// components/profile-form/auto-focus-step.tsx
"use client" ;
import { useEffect, useRef } from "react" ;
import { useProfileFormStore } from "@/stores/profile-form-store" ;
export function AutoFocusStep ({ children } : { children : React . ReactNode }) {
const
Key code points:
querySelector with a complex selector finds the first focusable, non-hidden, non-disabled input — works for text inputs, selects, and textareas
useEffect depends on currentStep — fires every time the step changes, moving focus to the new step's first field
containerRef wraps the step content — scopes the query to only the current step's DOM, not the entire page
Server Component page that wraps the client-side form in the provider. This is where App Router meets the form.
// app/profile/create/page.tsx
import { ProfileFormProvider } from "@/components/profile-form/profile-form-provider" ;
import { ProfileFormWizard } from "@/components/profile-form/profile-form-wizard" ;
export default function CreateProfilePage () {
return (
< main className = "max-w-2xl mx-auto py-10 px-4" >
< h1 className
Key code points:
This is a Server Component (no "use client") — it renders on the server and sends HTML to the client
ProfileFormProvider wraps ProfileFormWizard — the provider creates the form context, the wizard consumes it
The page is a thin shell — all logic lives in the components, the page just composes them
The route /profile/create maps to app/profile/create/page.tsx — Next.js App Router file-based routing
End-to-end tests that map directly to Gherkin scenarios. Uses the data-testid attributes added to every component.
// e2e/profile-form.spec.ts
import { test, expect, type Page } from "@playwright/test" ;
import path from "node:path" ;
const BASE_URL = "http://localhost:3000/profile/create" ;
// ── Helpers ────────────────────────────────────────────
async function fillStep1 ( page : Page ) {
Key code points:
Helper functions (fillStep1, fillStep2, etc.) mirror the Gherkin Given steps — reusable setup for each test
test.describe groups map to Gherkin Feature names — "Multi-Step Navigation", "Validation", "File Uploads", etc.
test.beforeEach navigates to the page — matches the Gherkin Background block
getByTestId() selectors target data-testid attributes — decoupled from CSS classes or text content
toHaveAttribute("data-status", "completed") asserts step indicator state — tests the same attribute the CSS uses for styling
toHaveValue("Jane Doe") after navigating back verifies data persistence — matches the Gherkin scenario "navigating back preserves data"
setInputFiles() with a Buffer.alloc(6 * 1024 * 1024) creates a synthetic oversized file — no real file needed for the rejection test
fillStep1 → clickNext → fillStep2 → clickNext → ... chains in the upload test navigate to step 5 — each step must pass validation before advancing
toBeVisible() assertions on error messages match Gherkin Then they should see "..." — the test verifies the exact user-visible error string
Accessibility tests verify aria-required, aria-invalid, and aria-current — ensures the ARIA contracts documented in the Gherkin spec are implemented
Gherkin Feature Files
│
▼
┌──────────────────────────────┐
│ lib/schemas/ │ ← Validation Rules
│ architect-profile.ts │ (shared zod, one source of truth)
└──────────┬───────────────────┘
│ imported by
┌─────┴─────┐
▼ ▼
┌──────────┐ ┌─────────────────────────┐
│ Client │ │ Server │ ← Dual Validation
│ zodRes. │ │ safeParse in action │
└────┬─────┘ └────────┬────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ react-hook- │ │ lib/actions/ │ ← Submission Logic
│ form + UI │ │ create-profile.ts │
│ components │ │ (FormData → DB) │
└────┬─────────┘ └───────┬──────────────┘
│ │
▼ │
┌──────────────┐ │
│ zustand │ ◄───────┘ ← Step Logic + Error Routing
│ step store │ (FIELD_TO_STEP navigates to error step)
└────┬─────────┘
│
▼
┌──────────────┐
│ Playwright │ ← E2E tests verify Gherkin scenarios
│ e2e/ │ via data-testid selectors
└──────────────┘
Quick-reference: what goes where.
You need to... Put it in... Add a validation rule lib/schemas/architect-profile.ts (zod)Add a new form step New step component + add to STEP_COMPONENTS in wizard + update STEPS in store Change step navigation rules stores/profile-form-store.ts (canNavigateTo)Handle a new server error lib/actions/create-profile.ts + FIELD_TO_STEP in submit-stepAdd a file upload field uploadsSchema in schema + new <FileUpload> in uploads-stepWrite a new E2E test e2e/profile-form.spec.ts using data-testid selectorsChange acceptance criteria features/profile-form/*.feature first, then update code to match
Missing data-testid -- Playwright tests rely on data-testid attributes. Every interactive element needs one. Add them when you create a component, not later.
Schema file importing server modules -- lib/schemas/architect-profile.ts is shared between client and server. It must not import anything from "use server" files, next/headers, or database clients.
useFieldArray index drift -- when you remove a job entry, all subsequent indexes shift. Use field.id (from useFieldArray) as the React key, not the array index.
File previews not cleaned up -- every URL.createObjectURL must have a revokeObjectURL when the file is removed. The FileUpload component handles this, but if you build custom upload UI, track your URLs.
redirect() in server actions -- Next.js redirect() throws an internal error. Your try/catch in the server action must re-throw it, or the redirect silently fails.
Playwright file upload -- use page.locator('input[type=file]').setInputFiles() with a real file path or a Buffer. The visual drop zone is not a real file input, so target the hidden <input> inside it.
Alternative Use When Don't Use When Single page.tsx with all steps inline Prototype or under 10 fields Production form with 20+ fields Redux instead of zustand Already using Redux in the app Starting fresh (zustand is lighter) Conform instead of react-hook-form Progressive enhancement is critical Complex client interactions (drag/drop, previews) Vitest component tests instead of Playwright Fast unit-level form testing Need to test real browser behavior (file uploads, navigation)
"Full name is required"
And they should see "Email is required"
And the form should not advance to step 2
Scenario : Email format is validated
Given the architect is on step 1 "Personal Info"
When they type "not-an-email" in the "Email" field
And they click "Next"
Then they should see "Please enter a valid email address"
Scenario : LinkedIn URL must point to linkedin.com
Given the architect is on step 1 "Personal Info"
When they type "https://twitter.com/someone" in the "LinkedIn URL" field
And they click "Next"
Then they should see "Must be a valid LinkedIn profile URL"
Scenario : Years of experience must be between 0 and 50
Given the architect is on step 2 "Experience"
When they type "-3" in the "Years of Experience" field
And they click "Next"
Then they should see "Must be between 0 and 50 years"
Scenario : At least one cloud platform is required
Given the architect is on step 4 "Skills"
And no cloud platforms are checked
When they click "Next"
Then they should see "Select at least one cloud platform"
Scenario : Job end date must be after start date
Given the architect is on step 3 "Job History"
When they set start date to "2025-06-01"
And they set end date to "2024-01-01"
And they click "Next"
Then they should see "End date must be after start date"
Scenario : Bio cannot exceed 1000 characters
Given the architect is on step 2 "Experience"
When they type 1001 characters in the "Bio" field
Then they should see "Bio must be 1000 characters or fewer"
Scenario : Profile photo must be an image under 5MB
Given the architect is on step 5 "Uploads"
When they select a 15MB PNG for "Profile Photo"
Then they should see "File must be under 5MB"
Scenario : Profile photo rejects non-image files
Given the architect is on step 5 "Uploads"
When they select a .exe file for "Profile Photo"
Then they should see "Only JPEG, PNG, and WebP files are accepted"
=
10
;
// ── Reusable file validator ────────────────────────────
const imageFileSchema = z
. instanceof (File)
. refine (( file ) => file.size <= MAX_FILE_SIZE , "File must be under 5MB" )
. refine (
( file ) => ACCEPTED_IMAGE_TYPES . includes (file.type),
"Only JPEG, PNG, and WebP files are accepted"
);
// ── Step 1: Personal Info ──────────────────────────────
export const personalInfoSchema = z. object ({
fullName: z
. string ()
. min ( 1 , "Full name is required" )
. min ( 2 , "Name must be at least 2 characters" ),
email: z
. string ()
. min ( 1 , "Email is required" )
. email ( "Please enter a valid email address" ),
phone: z. string (). optional (),
linkedinUrl: z
. string ()
. url ( "Must be a valid URL" )
. refine (
( url ) => url. includes ( "linkedin.com/" ),
"Must be a valid LinkedIn profile URL"
)
. or (z. literal ( "" )),
});
// ── Step 2: Experience ─────────────────────────────────
export const experienceSchema = z. object ({
yearsOfExperience: z
. number ({ invalid_type_error: "Must be a number" })
. int ( "Must be a whole number" )
. min ( 0 , "Must be between 0 and 50 years" )
. max ( 50 , "Must be between 0 and 50 years" ),
currentRole: z. string (). min ( 1 , "Current role is required" ),
certifications: z. array (z. string ()),
bio: z. string (). max ( 1000 , "Bio must be 1000 characters or fewer" ). optional (),
});
// ── Step 3: Job History ────────────────────────────────
const jobEntrySchema = z
. object ({
company: z. string (). min ( 1 , "Company name is required" ),
role: z. string (). min ( 1 , "Role is required" ),
startDate: z. string (). min ( 1 , "Start date is required" ),
endDate: z. string (). optional (),
isCurrent: z. boolean (). default ( false ),
description: z. string (). max ( 500 ). optional (),
})
. refine (
( data ) => {
if (data.isCurrent || ! data.endDate) return true ;
return new Date (data.endDate) > new Date (data.startDate);
},
{ message: "End date must be after start date" , path: [ "endDate" ] }
);
export const jobHistorySchema = z. object ({
jobs: z. array (jobEntrySchema). min ( 1 , "Add at least one position" ),
});
// ── Step 4: Skills ─────────────────────────────────────
export const skillsSchema = z. object ({
cloudPlatforms: z
. array (z. enum ([ "aws" , "azure" , "gcp" ]))
. min ( 1 , "Select at least one cloud platform" ),
specialties: z. array (z. string ()). min ( 1 , "Select at least one specialty" ),
awsProficiency: z. enum ([ "beginner" , "intermediate" , "expert" ]). optional (),
azureProficiency: z. enum ([ "beginner" , "intermediate" , "expert" ]). optional (),
gcpProficiency: z. enum ([ "beginner" , "intermediate" , "expert" ]). optional (),
});
// ── Step 5: Uploads ────────────────────────────────────
export const uploadsSchema = z. object ({
profilePhoto: imageFileSchema. optional (),
architectureDiagrams: z
. array (imageFileSchema)
. max ( MAX_DIAGRAM_COUNT , `Maximum ${ MAX_DIAGRAM_COUNT } files allowed` )
. optional (),
siteScreenshots: z
. array (imageFileSchema)
. max ( MAX_DIAGRAM_COUNT , `Maximum ${ MAX_DIAGRAM_COUNT } files allowed` )
. optional (),
});
// ── Per-step schemas (used by StepNavigation.trigger()) ──
export const STEP_SCHEMAS = {
1 : personalInfoSchema,
2 : experienceSchema,
3 : jobHistorySchema,
4 : skillsSchema,
5 : uploadsSchema,
} as const ;
// ── Combined schema (used by zodResolver + server action) ──
export const architectProfileSchema = personalInfoSchema
. merge (experienceSchema)
. merge (jobHistorySchema)
. merge (skillsSchema)
. merge (uploadsSchema);
// ── Inferred types ─────────────────────────────────────
export type ArchitectProfile = z . infer < typeof architectProfileSchema>;
export type PersonalInfo = z . infer < typeof personalInfoSchema>;
export type Experience = z . infer < typeof experienceSchema>;
export type JobHistory = z . infer < typeof jobHistorySchema>;
export type Skills = z . infer < typeof skillsSchema>;
export type Uploads = z . infer < typeof uploadsSchema>;
"Experience"
},
{ id: 3 , label: "Job History" },
{ id: 4 , label: "Skills" },
{ id: 5 , label: "Uploads" },
{ id: 6 , label: "Review & Submit" },
] as const ;
export const TOTAL_STEPS = STEPS . length ;
interface ProfileFormState {
currentStep : number ;
completedSteps : Set < number >;
goToNext : () => void ;
goToPrevious : () => void ;
goToStep : ( step : number ) => void ;
markCompleted : ( step : number ) => void ;
getStepStatus : ( step : number ) => StepStatus ;
canNavigateTo : ( step : number ) => boolean ;
reset : () => void ;
}
export const useProfileFormStore = create < ProfileFormState >(( set , get ) => ({
currentStep: 1 ,
completedSteps: new Set < number >(),
goToNext : () => {
const { currentStep } = get ();
if (currentStep < TOTAL_STEPS ) {
set (( state ) => {
const completed = new Set (state.completedSteps);
completed. add (currentStep);
return { currentStep: currentStep + 1 , completedSteps: completed };
});
}
},
goToPrevious : () => {
const { currentStep } = get ();
if (currentStep > 1 ) {
set ({ currentStep: currentStep - 1 });
}
},
goToStep : ( step : number ) => {
const { canNavigateTo } = get ();
if ( canNavigateTo (step)) {
set ({ currentStep: step });
}
},
markCompleted : ( step : number ) => {
set (( state ) => {
const completed = new Set (state.completedSteps);
completed. add (step);
return { completedSteps: completed };
});
},
getStepStatus : ( step : number ) : StepStatus => {
const { currentStep , completedSteps } = get ();
if (step === currentStep) return "current" ;
if (completedSteps. has (step)) return "completed" ;
return "upcoming" ;
},
canNavigateTo : ( step : number ) : boolean => {
const { completedSteps , currentStep } = get ();
if (step === currentStep) return true ;
if (completedSteps. has (step)) return true ;
if (step === currentStep + 1 ) return true ;
return false ;
},
reset : () => set ({ currentStep: 1 , completedSteps: new Set () }),
}));
. refine (( file ) => file.size <= 5 * 1024 * 1024 , "File must be under 5MB" )
. refine (
( file ) => [ "image/jpeg" , "image/png" , "image/webp" ]. includes (file.type),
"Invalid file type"
);
export type ProfileActionState = {
success : boolean ;
message : string ;
fieldErrors : Record < string , string >;
};
const initialState : ProfileActionState = {
success: false ,
message: "" ,
fieldErrors: {},
};
export async function createProfile (
prevState : ProfileActionState ,
formData : FormData
) : Promise < ProfileActionState > {
try {
// ── 1. Parse text fields from FormData ───────────
const rawData = {
fullName: formData. get ( "fullName" ) as string ,
email: formData. get ( "email" ) as string ,
phone: formData. get ( "phone" ) as string ,
linkedinUrl: formData. get ( "linkedinUrl" ) as string ,
yearsOfExperience: Number (formData. get ( "yearsOfExperience" )),
currentRole: formData. get ( "currentRole" ) as string ,
certifications: formData. getAll ( "certifications" ) as string [],
bio: formData. get ( "bio" ) as string ,
jobs: JSON . parse (formData. get ( "jobs" ) as string ),
cloudPlatforms: formData. getAll ( "cloudPlatforms" ) as string [],
specialties: formData. getAll ( "specialties" ) as string [],
awsProficiency: formData. get ( "awsProficiency" ) as string ,
azureProficiency: formData. get ( "azureProficiency" ) as string ,
gcpProficiency: formData. get ( "gcpProficiency" ) as string ,
};
// ── 2. Validate text fields (same schema as client) ──
const textSchema = architectProfileSchema. omit ({
profilePhoto: true ,
architectureDiagrams: true ,
siteScreenshots: true ,
});
const textResult = textSchema. safeParse (rawData);
if ( ! textResult.success) {
const fieldErrors : Record < string , string > = {};
for ( const issue of textResult.error.issues) {
const path = issue.path. join ( "." );
fieldErrors[path] = issue.message;
}
return { success: false , message: "" , fieldErrors };
}
// ── 3. Validate files server-side (dual validation) ──
const profilePhoto = formData. get ( "profilePhoto" ) as File | null ;
if (profilePhoto && profilePhoto.size > 0 ) {
const fileResult = serverFileSchema. safeParse (profilePhoto);
if ( ! fileResult.success) {
return {
success: false ,
message: "" ,
fieldErrors: { profilePhoto: fileResult.error.issues[ 0 ].message },
};
}
}
const diagrams = formData. getAll ( "architectureDiagrams" ) as File [];
for ( const diagram of diagrams) {
if (diagram.size > 0 ) {
const fileResult = serverFileSchema. safeParse (diagram);
if ( ! fileResult.success) {
return {
success: false ,
message: "" ,
fieldErrors: {
architectureDiagrams: fileResult.error.issues[ 0 ].message,
},
};
}
}
}
const screenshots = formData. getAll ( "siteScreenshots" ) as File [];
for ( const screenshot of screenshots) {
if (screenshot.size > 0 ) {
const fileResult = serverFileSchema. safeParse (screenshot);
if ( ! fileResult.success) {
return {
success: false ,
message: "" ,
fieldErrors: {
siteScreenshots: fileResult.error.issues[ 0 ].message,
},
};
}
}
}
// ── 4. Check for duplicate email ─────────────────
const existingProfile = await findProfileByEmail (textResult.data.email);
if (existingProfile) {
return {
success: false ,
message: "" ,
fieldErrors: { email: "This email is already registered" },
};
}
// ── 5. Upload files to storage ───────────────────
const photoUrl = profilePhoto?.size
? await uploadToStorage (profilePhoto, "profiles" )
: null ;
const diagramUrls = await Promise . all (
diagrams
. filter (( f ) => f.size > 0 )
. map (( f ) => uploadToStorage (f, "diagrams" ))
);
const screenshotUrls = await Promise . all (
screenshots
. filter (( f ) => f.size > 0 )
. map (( f ) => uploadToStorage (f, "screenshots" ))
);
// ── 6. Create profile record ─────────────────────
const profile = await createProfileRecord ({
... textResult.data,
photoUrl,
diagramUrls,
screenshotUrls,
});
redirect ( `/profile/${ profile . id }` );
} catch (error) {
// redirect() throws internally -- rethrow it
if (error instanceof Error && error.message === "NEXT_REDIRECT" ) {
throw error;
}
console. error ( "Profile creation failed:" , error);
return {
success: false ,
message: "Something went wrong. Please try again." ,
fieldErrors: {},
};
}
}
// Replace with your actual DB/storage layer
async function findProfileByEmail ( email : string ) {
return null ;
}
async function uploadToStorage ( file : File , folder : string ) : Promise < string > {
return `https://storage.example.com/${ folder }/${ file . name }` ;
}
async function createProfileRecord ( data : Record < string , unknown >) {
return { id: "new-profile-id" };
}
<
ArchitectProfile
>
=
{
fullName: "" ,
email: "" ,
phone: "" ,
linkedinUrl: "" ,
yearsOfExperience: 0 ,
currentRole: "" ,
certifications: [],
bio: "" ,
jobs: [
{ company: "" , role: "" , startDate: "" , endDate: "" , isCurrent: false , description: "" },
],
cloudPlatforms: [],
specialties: [],
};
export function ProfileFormProvider ({ children } : { children : React . ReactNode }) {
const methods = useForm < ArchitectProfile >({
resolver: zodResolver (architectProfileSchema),
defaultValues,
mode: "onBlur" ,
});
return < FormProvider { ... methods}>{children}</ FormProvider >;
}
;
import { PersonalInfoStep } from "./personal-info-step" ;
import { ExperienceStep } from "./experience-step" ;
import { JobHistoryStep } from "./job-history-step" ;
import { SkillsStep } from "./skills-step" ;
import { UploadsStep } from "./uploads-step" ;
import { SubmitStep } from "./submit-step" ;
const STEP_COMPONENTS : Record < number , React . ComponentType > = {
1 : PersonalInfoStep,
2 : ExperienceStep,
3 : JobHistoryStep,
4 : SkillsStep,
5 : UploadsStep,
6 : SubmitStep,
};
export function ProfileFormWizard () {
const { currentStep } = useProfileFormStore ();
const StepComponent = STEP_COMPONENTS [currentStep];
return (
< div className = "space-y-8" data-testid = "profile-form-wizard" >
< StepIndicator />
< StepAnnouncer />
< AutoFocusStep >
< StepComponent />
</ AutoFocusStep >
< StepNavigation />
</ div >
);
}
,
canNavigateTo
,
goToStep
}
=
useProfileFormStore ();
return (
< nav aria-label = "Form progress" data-testid = "step-indicator" >
< p className = "sr-only" >
Step {currentStep} of { STEPS . length }
</ p >
< ol className = "flex items-center gap-2" >
{ STEPS . map (( step ) => {
const status = getStepStatus (step.id);
const navigable = canNavigateTo (step.id);
return (
< li key = {step.id} className = "flex items-center gap-2" >
< button
type = "button"
onClick = {() => goToStep (step.id)}
disabled = { ! navigable}
aria-current = {status === "current" ? "step" : undefined }
aria-label = { `Step ${ step . id }: ${ step . label }${
status === "completed" ? " (completed)" : ""
}` }
data-testid = { `step-${ step . id }` }
data-status = {status}
className = { cn (
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors" ,
status === "current" && "bg-primary text-primary-foreground" ,
status === "completed" &&
"bg-primary/20 text-primary hover:bg-primary/30" ,
status === "upcoming" &&
"bg-muted text-muted-foreground cursor-not-allowed"
)}
>
{status === "completed" ? (
< Check className = "h-4 w-4" aria-hidden = "true" />
) : (
step.id
)}
</ button >
< span
className = { cn (
"hidden text-sm sm:inline" ,
status === "current" && "font-semibold" ,
status === "upcoming" && "text-muted-foreground"
)}
>
{step.label}
</ span >
{step.id < STEPS . length && (
< div
className = { cn (
"hidden h-px w-8 sm:block" ,
status === "completed" ? "bg-primary" : "bg-muted"
)}
aria-hidden = "true"
/>
)}
</ li >
);
})}
</ ol >
</ nav >
);
}
:
Record
<
number
, (
keyof
ArchitectProfile
)[]>
=
{
1 : [ "fullName" , "email" , "phone" , "linkedinUrl" ],
2 : [ "yearsOfExperience" , "currentRole" , "certifications" , "bio" ],
3 : [ "jobs" ],
4 : [ "cloudPlatforms" , "specialties" ],
5 : [ "profilePhoto" , "architectureDiagrams" , "siteScreenshots" ],
};
export function StepNavigation () {
const { currentStep , goToNext , goToPrevious } = useProfileFormStore ();
const { trigger } = useFormContext < ArchitectProfile >();
const handleNext = async () => {
const fields = STEP_FIELDS [currentStep];
const isValid = await trigger (fields);
if (isValid) {
goToNext ();
}
};
const isFirstStep = currentStep === 1 ;
const isLastStep = currentStep === TOTAL_STEPS ;
return (
< div className = "flex justify-between pt-6" data-testid = "step-navigation" >
{ ! isFirstStep ? (
< Button
type = "button"
variant = "outline"
onClick = {goToPrevious}
data-testid = "btn-back"
>
Back
</ Button >
) : (
< div />
)}
{ ! isLastStep ? (
< Button type = "button" onClick = {handleNext} data-testid = "btn-next" >
Next
</ Button >
) : (
< Button type = "submit" data-testid = "btn-submit" >
Submit Profile
</ Button >
)}
</ div >
);
}
from
"react"
;
interface AccessibleFieldProps {
name : keyof ArchitectProfile ;
label : string ;
type ?: HTMLInputTypeAttribute ;
required ?: boolean ;
placeholder ?: string ;
}
export function AccessibleField ({
name ,
label ,
type = "text" ,
required = false ,
placeholder ,
} : AccessibleFieldProps ) {
const {
register ,
formState : { errors },
} = useFormContext < ArchitectProfile >();
const error = errors[name];
const errorId = `${ name }-error` ;
return (
< div className = "space-y-1.5" >
< Label htmlFor = {name}>
{label}
{required && (
< span aria-hidden = "true" className = "text-destructive ml-0.5" >*</ span >
)}
</ Label >
< Input
id = {name}
type = {type}
placeholder = {placeholder}
aria-required = {required}
aria-invalid = { !! error}
aria-describedby = {error ? errorId : undefined }
data-testid = { `field-${ name }` }
{ ... register (name)}
/>
{error && (
< p id = {errorId} role = "alert" className = "text-sm text-destructive" >
{error.message as string }
</ p >
)}
</ div >
);
}
>
< div className = "space-y-4" >
< AccessibleField name = "fullName" label = "Full Name" required />
< AccessibleField name = "email" label = "Email" type = "email" required />
< AccessibleField name = "phone" label = "Phone" type = "tel" />
< AccessibleField
name = "linkedinUrl"
label = "LinkedIn URL"
type = "url"
placeholder = "https://linkedin.com/in/your-profile"
/>
</ div >
</ fieldset >
);
}
;
import type { ArchitectProfile } from "@/lib/schemas/architect-profile" ;
const CERTIFICATIONS = [
"AWS Solutions Architect Professional" ,
"AWS DevOps Engineer Professional" ,
"Google Cloud Professional Architect" ,
"Azure Solutions Architect Expert" ,
"Terraform Associate" ,
"Kubernetes Administrator (CKA)" ,
] as const ;
export function ExperienceStep () {
const {
register ,
watch ,
setValue ,
formState : { errors },
} = useFormContext < ArchitectProfile >();
const bio = watch ( "bio" ) ?? "" ;
const certifications = watch ( "certifications" ) ?? [];
const toggleCertification = ( cert : string ) => {
const updated = certifications. includes (cert)
? certifications. filter (( c ) => c !== cert)
: [ ... certifications, cert];
setValue ( "certifications" , updated, { shouldValidate: true });
};
return (
< fieldset data-testid = "step-experience" >
< legend className = "text-lg font-semibold mb-4" >Experience</ legend >
< div className = "space-y-4" >
< AccessibleField
name = "yearsOfExperience"
label = "Years of Experience"
type = "number"
required
/>
< AccessibleField name = "currentRole" label = "Current Role" required />
< div >
< Label >Certifications</ Label >
< div className = "grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2" >
{ CERTIFICATIONS . map (( cert ) => (
< label
key = {cert}
className = "flex items-center gap-2 text-sm cursor-pointer"
>
< Checkbox
checked = {certifications. includes (cert)}
onCheckedChange = {() => toggleCertification (cert)}
data-testid = { `cert-${ cert . toLowerCase (). replace ( / \s + / g , "-" ) }` }
/>
{cert}
</ label >
))}
</ div >
< p className = "text-xs text-muted-foreground mt-1" >
{certifications. length } selected
</ p >
</ div >
< div >
< Label htmlFor = "bio" >Bio</ Label >
< Textarea
id = "bio"
rows = { 4 }
aria-invalid = { !! errors.bio}
aria-describedby = {errors.bio ? "bio-error" : "bio-counter" }
data-testid = "field-bio"
{ ... register ( "bio" )}
/>
< p
id = "bio-counter"
className = { `text-xs mt-1 ${ bio . length > 1000 ? "text-destructive" : "text-muted-foreground"}` }
>
{bio. length } / 1000
</ p >
{errors.bio && (
< p id = "bio-error" role = "alert" className = "text-sm text-destructive" >
{errors.bio.message}
</ p >
)}
</ div >
</ div >
</ fieldset >
);
}
;
import { Checkbox } from "@/components/ui/checkbox" ;
import type { ArchitectProfile } from "@/lib/schemas/architect-profile" ;
const MAX_JOBS = 10 ;
export function JobHistoryStep () {
const {
register ,
control ,
formState : { errors },
watch ,
} = useFormContext < ArchitectProfile >();
const { fields , append , remove } = useFieldArray ({ control, name: "jobs" });
return (
< fieldset data-testid = "step-job-history" >
< legend className = "text-lg font-semibold mb-4" >Job History</ legend >
{fields. map (( field , index ) => {
const isCurrent = watch ( `jobs.${ index }.isCurrent` );
return (
< div
key = {field.id}
className = "border rounded-lg p-4 space-y-3 mb-4"
data-testid = { `job-entry-${ index }` }
>
< div className = "flex justify-between items-center" >
< h3 className = "font-medium" >Position {index + 1 }</ h3 >
{fields. length > 1 && (
< Button
type = "button"
variant = "ghost"
size = "sm"
onClick = {() => remove (index)}
data-testid = { `remove-job-${ index }` }
>
Remove
</ Button >
)}
</ div >
< div className = "grid grid-cols-2 gap-4" >
< div >
< Label htmlFor = { `jobs.${ index }.company` }>Company *</ Label >
< Input
id = { `jobs.${ index }.company` }
aria-required = "true"
data-testid = { `job-${ index }-company` }
{ ... register ( `jobs.${ index }.company` )}
/>
{errors.jobs?.[index]?.company && (
< p role = "alert" className = "text-sm text-destructive mt-1" >
{errors.jobs[index].company.message}
</ p >
)}
</ div >
< div >
< Label htmlFor = { `jobs.${ index }.role` }>Role *</ Label >
< Input
id = { `jobs.${ index }.role` }
aria-required = "true"
data-testid = { `job-${ index }-role` }
{ ... register ( `jobs.${ index }.role` )}
/>
</ div >
</ div >
< div className = "grid grid-cols-2 gap-4" >
< div >
< Label htmlFor = { `jobs.${ index }.startDate` }>Start Date *</ Label >
< Input
id = { `jobs.${ index }.startDate` }
type = "date"
aria-required = "true"
data-testid = { `job-${ index }-start` }
{ ... register ( `jobs.${ index }.startDate` )}
/>
</ div >
< div >
< Label htmlFor = { `jobs.${ index }.endDate` }>End Date</ Label >
< Input
id = { `jobs.${ index }.endDate` }
type = "date"
disabled = {isCurrent}
data-testid = { `job-${ index }-end` }
{ ... register ( `jobs.${ index }.endDate` )}
/>
{errors.jobs?.[index]?.endDate && (
< p role = "alert" className = "text-sm text-destructive mt-1" >
{errors.jobs[index].endDate.message}
</ p >
)}
</ div >
</ div >
< div className = "flex items-center gap-2" >
< Checkbox
id = { `jobs.${ index }.isCurrent` }
{ ... register ( `jobs.${ index }.isCurrent` )}
data-testid = { `job-${ index }-current` }
/>
< Label htmlFor = { `jobs.${ index }.isCurrent` }>I currently work here</ Label >
</ div >
< div >
< Label htmlFor = { `jobs.${ index }.description` }>Description</ Label >
< Textarea
id = { `jobs.${ index }.description` }
rows = { 3 }
data-testid = { `job-${ index }-description` }
{ ... register ( `jobs.${ index }.description` )}
/>
</ div >
</ div >
);
})}
{fields. length < MAX_JOBS && (
< Button
type = "button"
variant = "outline"
onClick = {() =>
append ({
company: "" ,
role: "" ,
startDate: "" ,
endDate: "" ,
isCurrent: false ,
description: "" ,
})
}
data-testid = "add-job"
>
Add Another Position
</ Button >
)}
{errors.jobs?.root && (
< p role = "alert" className = "text-sm text-destructive mt-2" >
{errors.jobs.root.message}
</ p >
)}
</ fieldset >
);
}
"@/lib/schemas/architect-profile"
;
const PLATFORMS = [
{ value: "aws" as const , label: "Amazon Web Services (AWS)" },
{ value: "azure" as const , label: "Microsoft Azure" },
{ value: "gcp" as const , label: "Google Cloud Platform (GCP)" },
];
const SPECIALTIES = [
"Serverless" ,
"Containers & Kubernetes" ,
"Networking & Security" ,
"Data & Analytics" ,
"Machine Learning" ,
"DevOps & CI/CD" ,
"Cost Optimization" ,
"Migration" ,
];
const PROFICIENCY_LEVELS = [ "beginner" , "intermediate" , "expert" ] as const ;
export function SkillsStep () {
const {
watch ,
setValue ,
formState : { errors },
} = useFormContext < ArchitectProfile >();
const cloudPlatforms = watch ( "cloudPlatforms" ) ?? [];
const specialties = watch ( "specialties" ) ?? [];
const togglePlatform = ( platform : "aws" | "azure" | "gcp" ) => {
const updated = cloudPlatforms. includes (platform)
? cloudPlatforms. filter (( p ) => p !== platform)
: [ ... cloudPlatforms, platform];
setValue ( "cloudPlatforms" , updated, { shouldValidate: true });
};
const toggleSpecialty = ( specialty : string ) => {
const updated = specialties. includes (specialty)
? specialties. filter (( s ) => s !== specialty)
: [ ... specialties, specialty];
setValue ( "specialties" , updated, { shouldValidate: true });
};
return (
< fieldset data-testid = "step-skills" >
< legend className = "text-lg font-semibold mb-4" >Skills</ legend >
< div className = "space-y-6" >
{ /* Cloud Platforms */ }
< div >
< Label >Cloud Platforms *</ Label >
< div className = "space-y-2 mt-2" >
{ PLATFORMS . map (( platform ) => (
< label key = {platform.value} className = "flex items-center gap-2 text-sm" >
< Checkbox
checked = {cloudPlatforms. includes (platform.value)}
onCheckedChange = {() => togglePlatform (platform.value)}
data-testid = { `platform-${ platform . value }` }
/>
{platform.label}
</ label >
))}
</ div >
{errors.cloudPlatforms && (
< p role = "alert" className = "text-sm text-destructive mt-1" >
{errors.cloudPlatforms.message}
</ p >
)}
</ div >
{ /* Proficiency per selected platform */ }
{cloudPlatforms. map (( platform ) => {
const fieldName = `${ platform }Proficiency` as keyof ArchitectProfile ;
const currentValue = watch (fieldName) as string | undefined ;
return (
< div key = {platform}>
< Label >{platform. toUpperCase ()} Proficiency</ Label >
< RadioGroup
value = {currentValue}
onValueChange = {( val ) => setValue (fieldName, val, { shouldValidate: true })}
className = "flex gap-4 mt-2"
data-testid = { `proficiency-${ platform }` }
>
{ PROFICIENCY_LEVELS . map (( level ) => (
< label key = {level} className = "flex items-center gap-1.5 text-sm capitalize" >
< RadioGroupItem value = {level} />
{level}
</ label >
))}
</ RadioGroup >
</ div >
);
})}
{ /* Specialties */ }
< div >
< Label >Specialties *</ Label >
< div className = "grid grid-cols-2 gap-2 mt-2" >
{ SPECIALTIES . map (( specialty ) => (
< label key = {specialty} className = "flex items-center gap-2 text-sm" >
< Checkbox
checked = {specialties. includes (specialty)}
onCheckedChange = {() => toggleSpecialty (specialty)}
data-testid = { `specialty-${ specialty . toLowerCase (). replace ( / \s + & ? \s * / g , "-" ) }` }
/>
{specialty}
</ label >
))}
</ div >
{errors.specialties && (
< p role = "alert" className = "text-sm text-destructive mt-1" >
{errors.specialties.message}
</ p >
)}
</ div >
</ div >
</ fieldset >
);
}
;
import type { ArchitectProfile } from "@/lib/schemas/architect-profile" ;
const ACCEPTED_TYPES = [ "image/jpeg" , "image/png" , "image/webp" ];
const MAX_SIZE = 5 * 1024 * 1024 ;
interface FileUploadProps {
name : "profilePhoto" | "architectureDiagrams" | "siteScreenshots" ;
label : string ;
multiple ?: boolean ;
maxFiles ?: number ;
}
function formatFileSize ( bytes : number ) : string {
if (bytes < 1024 ) return `${ bytes } B` ;
if (bytes < 1024 * 1024 ) return `${ ( bytes / 1024 ). toFixed ( 0 ) } KB` ;
return `${ ( bytes / ( 1024 * 1024 )). toFixed ( 1 ) } MB` ;
}
export function FileUpload ({ name , label , multiple = false , maxFiles = 10 } : FileUploadProps ) {
const { control , clearErrors } = useFormContext < ArchitectProfile >();
const { field } = useController ({ control, name });
const [ isDragging , setIsDragging ] = useState ( false );
const [ previews , setPreviews ] = useState <{ file : File ; url : string }[]>([]);
const [ clientError , setClientError ] = useState < string | null >( null );
const inputRef = useRef < HTMLInputElement >( null );
const files : File [] = multiple
? (field.value as File []) ?? []
: field.value
? [field.value as File ]
: [];
const isMaxReached = multiple && files. length >= maxFiles;
const validateFile = useCallback (
( file : File ) : string | null => {
if ( ! ACCEPTED_TYPES . includes (file.type)) return "Only JPEG, PNG, and WebP files are accepted" ;
if (file.size > MAX_SIZE ) return "File must be under 5MB" ;
if (multiple && files. length >= maxFiles) return `Maximum ${ maxFiles } files allowed` ;
return null ;
},
[files. length , maxFiles, multiple]
);
const addFiles = useCallback (
( newFiles : FileList | File []) => {
setClientError ( null );
clearErrors (name);
for ( const file of Array. from (newFiles)) {
const error = validateFile (file);
if (error) { setClientError (error); return ; }
}
const validFiles = Array. from (newFiles);
const newPreviews = validFiles. map (( file ) => ({
file,
url: URL . createObjectURL (file),
}));
if (multiple) {
field. onChange ([ ... files, ... validFiles]);
setPreviews (( prev ) => [ ... prev, ... newPreviews]);
} else {
previews. forEach (( p ) => URL . revokeObjectURL (p.url));
field. onChange (validFiles[ 0 ]);
setPreviews (newPreviews. slice ( 0 , 1 ));
}
},
[field, files, multiple, name, clearErrors, validateFile, previews]
);
const removeFile = useCallback (
( index : number ) => {
URL . revokeObjectURL (previews[index].url);
setClientError ( null );
if (multiple) {
field. onChange (files. filter (( _ , i ) => i !== index));
setPreviews (( prev ) => prev. filter (( _ , i ) => i !== index));
} else {
field. onChange ( undefined );
setPreviews ([]);
}
if (inputRef.current) inputRef.current.value = "" ;
},
[field, files, multiple, previews]
);
const handleDragOver = useCallback (( e : React . DragEvent ) => { e. preventDefault (); setIsDragging ( true ); }, []);
const handleDragLeave = useCallback (( e : React . DragEvent ) => { e. preventDefault (); setIsDragging ( false ); }, []);
const handleDrop = useCallback (
( e : React . DragEvent ) => {
e. preventDefault ();
setIsDragging ( false );
if (e.dataTransfer.files. length > 0 ) addFiles (e.dataTransfer.files);
},
[addFiles]
);
return (
< div className = "space-y-3" data-testid = { `upload-${ name }` }>
< label className = "text-sm font-medium" >{label}</ label >
< div
onDragOver = {handleDragOver}
onDragLeave = {handleDragLeave}
onDrop = {handleDrop}
onClick = {() => ! isMaxReached && inputRef.current?. click ()}
role = "button"
tabIndex = { 0 }
aria-label = { `Upload ${ label }` }
aria-disabled = {isMaxReached}
onKeyDown = {( e ) => {
if (e.key === "Enter" || e.key === " " ) { e. preventDefault (); if ( ! isMaxReached) inputRef.current?. click (); }
}}
className = { cn (
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-ring" ,
isDragging && "border-primary bg-primary/5" ,
! isDragging && "border-muted-foreground/25 hover:border-muted-foreground/50" ,
isMaxReached && "cursor-not-allowed opacity-50"
)}
>
< Upload className = "h-8 w-8 text-muted-foreground mb-2" aria-hidden = "true" />
< p className = "text-sm text-muted-foreground" >
{isDragging ? "Drop file here" : isMaxReached ? `Maximum ${ maxFiles } files allowed` : "Click or drag files here" }
</ p >
< p className = "text-xs text-muted-foreground mt-1" >JPEG, PNG, or WebP up to 5MB</ p >
</ div >
< input
ref = {inputRef}
type = "file"
accept = "image/jpeg,image/png,image/webp"
multiple = {multiple}
disabled = {isMaxReached}
onChange = {( e ) => e.target.files && addFiles (e.target.files)}
className = "sr-only"
aria-hidden = "true"
/>
{clientError && (
< p role = "alert" className = "text-sm text-destructive" data-testid = { `${ name }-error` }>
{clientError}
</ p >
)}
{multiple && files. length > 0 && (
< p className = "text-sm text-muted-foreground" >{files. length } {files. length === 1 ? "file" : "files" }</ p >
)}
{previews. length > 0 && (
< div className = "grid grid-cols-2 sm:grid-cols-3 gap-3" >
{previews. map (( preview , index ) => (
< div key = {preview.url} className = "relative group rounded-lg overflow-hidden border" >
< img src = {preview.url} alt = {preview.file.name} className = "w-full h-24 object-cover" />
< div className = "p-2 text-xs" >
< p className = "truncate font-medium" >{preview.file.name}</ p >
< p className = "text-muted-foreground" >{ formatFileSize (preview.file.size)}</ p >
</ div >
< Button
type = "button"
variant = "destructive"
size = "icon"
className = "absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick = {( e ) => { e. stopPropagation (); removeFile (index); }}
aria-label = { `Remove ${ preview . file . name }` }
data-testid = { `remove-file-${ index }` }
>
< X className = "h-3 w-3" />
</ Button >
</ div >
))}
</ div >
)}
</ div >
);
}
>
< div className = "space-y-6" >
< FileUpload name = "profilePhoto" label = "Profile Photo" />
< FileUpload name = "architectureDiagrams" label = "Architecture Diagrams" multiple maxFiles = { 10 } />
< FileUpload name = "siteScreenshots" label = "Site Screenshots" multiple maxFiles = { 10 } />
</ div >
</ fieldset >
);
}
ProfileActionState,
} from "@/lib/actions/create-profile" ;
import { useProfileFormStore, STEPS } from "@/stores/profile-form-store" ;
import { useToast } from "@/hooks/use-toast" ;
import type { ArchitectProfile } from "@/lib/schemas/architect-profile" ;
const FIELD_TO_STEP : Record < string , number > = {
fullName: 1 , email: 1 , phone: 1 , linkedinUrl: 1 ,
yearsOfExperience: 2 , currentRole: 2 , certifications: 2 , bio: 2 ,
jobs: 3 ,
cloudPlatforms: 4 , specialties: 4 ,
profilePhoto: 5 , architectureDiagrams: 5 , siteScreenshots: 5 ,
};
const initialState : ProfileActionState = {
success: false ,
message: "" ,
fieldErrors: {},
};
export function SubmitStep () {
const [ state , formAction , isPending ] = useActionState (createProfile, initialState);
const { getValues , setError } = useFormContext < ArchitectProfile >();
const { goToStep } = useProfileFormStore ();
const { toast } = useToast ();
// Map server field errors back to react-hook-form + navigate to step
useEffect (() => {
if ( ! state.fieldErrors || Object. keys (state.fieldErrors). length === 0 ) return ;
const firstErrorField = Object. keys (state.fieldErrors)[ 0 ];
for ( const [ field , message ] of Object. entries (state.fieldErrors)) {
setError (field as keyof ArchitectProfile , { message });
}
const targetStep = FIELD_TO_STEP [firstErrorField];
if (targetStep) goToStep (targetStep);
}, [state.fieldErrors, setError, goToStep]);
// Show toast for generic server errors
useEffect (() => {
if (state.message && ! state.success) {
toast ({ variant: "destructive" , title: "Submission failed" , description: state.message });
}
}, [state.message, state.success, toast]);
// Build FormData from react-hook-form for the server action
const handleSubmit = () => {
const values = getValues ();
const formData = new FormData ();
formData. set ( "fullName" , values.fullName);
formData. set ( "email" , values.email);
formData. set ( "phone" , values.phone ?? "" );
formData. set ( "linkedinUrl" , values.linkedinUrl ?? "" );
formData. set ( "yearsOfExperience" , String (values.yearsOfExperience));
formData. set ( "currentRole" , values.currentRole);
formData. set ( "bio" , values.bio ?? "" );
formData. set ( "jobs" , JSON . stringify (values.jobs));
for ( const cert of values.certifications) formData. append ( "certifications" , cert);
for ( const platform of values.cloudPlatforms) formData. append ( "cloudPlatforms" , platform);
for ( const specialty of values.specialties) formData. append ( "specialties" , specialty);
if (values.profilePhoto) formData. set ( "profilePhoto" , values.profilePhoto);
if (values.architectureDiagrams) {
for ( const d of values.architectureDiagrams) formData. append ( "architectureDiagrams" , d);
}
if (values.siteScreenshots) {
for ( const s of values.siteScreenshots) formData. append ( "siteScreenshots" , s);
}
formAction (formData);
};
const values = getValues ();
return (
< div data-testid = "step-review" >
< h2 className = "text-lg font-semibold mb-4" >Review Your Profile</ h2 >
< div className = "space-y-4" >
< ReviewCard title = "Personal Info" step = { 1 } items = {[
{ label: "Name" , value: values.fullName },
{ label: "Email" , value: values.email },
{ label: "Phone" , value: values.phone },
{ label: "LinkedIn" , value: values.linkedinUrl },
]} />
< ReviewCard title = "Experience" step = { 2 } items = {[
{ label: "Years" , value: String (values.yearsOfExperience) },
{ label: "Role" , value: values.currentRole },
{ label: "Certifications" , value: values.certifications. join ( ", " ) },
]} />
< ReviewCard title = "Job History" step = { 3 } items = {
values.jobs. map (( j ) => ({ label: j.company, value: j.role }))
} />
< ReviewCard title = "Skills" step = { 4 } items = {[
{ label: "Platforms" , value: values.cloudPlatforms. join ( ", " ) },
{ label: "Specialties" , value: values.specialties. join ( ", " ) },
]} />
< ReviewCard title = "Uploads" step = { 5 } items = {[
{ label: "Profile Photo" , value: values.profilePhoto ? "Uploaded" : "None" },
{ label: "Diagrams" , value: `${ values . architectureDiagrams ?. length ?? 0 } files` },
{ label: "Screenshots" , value: `${ values . siteScreenshots ?. length ?? 0 } files` },
]} />
</ div >
< div className = "pt-6" >
< Button
type = "button"
onClick = {handleSubmit}
disabled = {isPending}
className = "w-full"
data-testid = "btn-submit-profile"
>
{isPending ? (
<>
< Loader2 className = "mr-2 h-4 w-4 animate-spin" aria-hidden = "true" />
Submitting...
</>
) : (
"Submit Profile"
)}
</ Button >
</ div >
</ div >
);
}
function ReviewCard ({
title ,
step ,
items ,
} : {
title : string ;
step : number ;
items : { label : string ; value ?: string }[];
}) {
const { goToStep } = useProfileFormStore ();
return (
< div className = "border rounded-lg p-4" data-testid = { `review-${ step }` }>
< div className = "flex justify-between items-center mb-2" >
< h3 className = "font-medium" >{title}</ h3 >
< Button
type = "button"
variant = "ghost"
size = "sm"
onClick = {() => goToStep (step)}
data-testid = { `edit-step-${ step }` }
>
Edit
</ Button >
</ div >
< dl className = "grid grid-cols-2 gap-2 text-sm" >
{items. map (( item ) => (
< div key = {item.label}>
< dt className = "text-muted-foreground" >{item.label}</ dt >
< dd >{item.value || " \u2014 " }</ dd >
</ div >
))}
</ dl >
</ div >
);
}
useRef
<
HTMLDivElement
>(
null
);
const previousStep = useRef (currentStep);
useEffect (() => {
if (currentStep !== previousStep.current) {
previousStep.current = currentStep;
const stepLabel = STEPS [currentStep - 1 ].label;
if (announcerRef.current) {
announcerRef.current.textContent = `Step ${ currentStep } of ${ STEPS . length }: ${ stepLabel }` ;
}
}
}, [currentStep]);
return (
< div
ref = {announcerRef}
role = "status"
aria-live = "polite"
aria-atomic = "true"
className = "sr-only"
/>
);
}
{
currentStep
}
=
useProfileFormStore
();
const containerRef = useRef < HTMLDivElement >( null );
useEffect (() => {
const firstInput = containerRef.current?. querySelector < HTMLElement >(
"input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled])"
);
firstInput?. focus ();
}, [currentStep]);
return < div ref = {containerRef}>{children}</ div >;
}
=
"text-2xl font-bold mb-6"
>Create Your Profile</
h1
>
< ProfileFormProvider >
< ProfileFormWizard />
</ ProfileFormProvider >
</ main >
);
}
await page. getByTestId ( "field-fullName" ). fill ( "Jane Doe" );
await page. getByTestId ( "field-email" ). fill ( "jane@example.com" );
await page. getByTestId ( "field-phone" ). fill ( "+1-555-0100" );
}
async function fillStep2 ( page : Page ) {
await page. getByTestId ( "field-yearsOfExperience" ). fill ( "8" );
await page. getByTestId ( "field-currentRole" ). clear ();
await page. getByTestId ( "field-currentRole" ). fill ( "Principal Architect" );
await page. getByTestId ( "cert-aws-solutions-architect-professional" ). click ();
}
async function fillStep3 ( page : Page ) {
await page. getByTestId ( "job-0-company" ). fill ( "Acme Corp" );
await page. getByTestId ( "job-0-role" ). fill ( "Cloud Architect" );
await page. getByTestId ( "job-0-start" ). fill ( "2020-01-01" );
await page. getByTestId ( "job-0-current" ). click ();
}
async function fillStep4 ( page : Page ) {
await page. getByTestId ( "platform-aws" ). click ();
await page. getByTestId ( "specialty-serverless" ). click ();
}
async function clickNext ( page : Page ) {
await page. getByTestId ( "btn-next" ). click ();
}
// ── Tests mapped to Gherkin features ───────────────────
test. describe ( "Feature: Multi-Step Navigation" , () => {
test. beforeEach ( async ({ page }) => {
await page. goto ( BASE_URL );
});
test ( "initial state shows step 1" , async ({ page }) => {
await expect (page. getByTestId ( "step-personal-info" )). toBeVisible ();
await expect (page. getByTestId ( "step-1" )). toHaveAttribute ( "data-status" , "current" );
await expect (page. getByTestId ( "btn-back" )).not. toBeVisible ();
});
test ( "completing step 1 unlocks step 2" , async ({ page }) => {
await fillStep1 (page);
await clickNext (page);
await expect (page. getByTestId ( "step-experience" )). toBeVisible ();
await expect (page. getByTestId ( "step-1" )). toHaveAttribute ( "data-status" , "completed" );
await expect (page. getByTestId ( "btn-back" )). toBeVisible ();
});
test ( "navigating back preserves data" , async ({ page }) => {
await fillStep1 (page);
await clickNext (page);
await page. getByTestId ( "btn-back" ). click ();
await expect (page. getByTestId ( "field-fullName" )). toHaveValue ( "Jane Doe" );
});
});
test. describe ( "Feature: Validation" , () => {
test. beforeEach ( async ({ page }) => {
await page. goto ( BASE_URL );
});
test ( "required fields show errors when empty" , async ({ page }) => {
await clickNext (page);
await expect (page. getByText ( "Full name is required" )). toBeVisible ();
await expect (page. getByText ( "Email is required" )). toBeVisible ();
await expect (page. getByTestId ( "step-personal-info" )). toBeVisible ();
});
test ( "email format is validated" , async ({ page }) => {
await page. getByTestId ( "field-fullName" ). fill ( "Jane Doe" );
await page. getByTestId ( "field-email" ). fill ( "not-an-email" );
await clickNext (page);
await expect (page. getByText ( "Please enter a valid email address" )). toBeVisible ();
});
test ( "job end date must be after start date" , async ({ page }) => {
await fillStep1 (page);
await clickNext (page);
await fillStep2 (page);
await clickNext (page);
await page. getByTestId ( "job-0-company" ). fill ( "Acme" );
await page. getByTestId ( "job-0-role" ). fill ( "Architect" );
await page. getByTestId ( "job-0-start" ). fill ( "2025-06-01" );
await page. getByTestId ( "job-0-end" ). fill ( "2024-01-01" );
await clickNext (page);
await expect (page. getByText ( "End date must be after start date" )). toBeVisible ();
});
});
test. describe ( "Feature: File Uploads" , () => {
test. beforeEach ( async ({ page }) => {
await page. goto ( BASE_URL );
// Navigate to step 5
await fillStep1 (page); await clickNext (page);
await fillStep2 (page); await clickNext (page);
await fillStep3 (page); await clickNext (page);
await fillStep4 (page); await clickNext (page);
});
test ( "upload shows preview with filename and size" , async ({ page }) => {
const filePath = path. resolve ( "e2e/fixtures/sample.jpg" );
const input = page. getByTestId ( "upload-profilePhoto" ). locator ( "input[type=file]" );
await input. setInputFiles (filePath);
await expect (page. getByText ( "sample.jpg" )). toBeVisible ();
});
test ( "rejects oversized file" , async ({ page }) => {
// Create a 6MB buffer for testing
const input = page. getByTestId ( "upload-profilePhoto" ). locator ( "input[type=file]" );
await input. setInputFiles ({
name: "huge.jpg" ,
mimeType: "image/jpeg" ,
buffer: Buffer. alloc ( 6 * 1024 * 1024 ),
});
await expect (page. getByTestId ( "profilePhoto-error" )). toHaveText ( "File must be under 5MB" );
});
});
test. describe ( "Feature: Submission UX" , () => {
test ( "submit button shows loading state" , async ({ page }) => {
await page. goto ( BASE_URL );
await fillStep1 (page); await clickNext (page);
await fillStep2 (page); await clickNext (page);
await fillStep3 (page); await clickNext (page);
await fillStep4 (page); await clickNext (page);
// Skip uploads
await clickNext (page);
// On review step
await expect (page. getByTestId ( "step-review" )). toBeVisible ();
await page. getByTestId ( "btn-submit-profile" ). click ();
await expect (page. getByText ( "Submitting..." )). toBeVisible ();
});
});
test. describe ( "Feature: Accessibility" , () => {
test ( "required fields have aria-required" , async ({ page }) => {
await page. goto ( BASE_URL );
await expect (page. getByTestId ( "field-fullName" )). toHaveAttribute ( "aria-required" , "true" );
await expect (page. getByTestId ( "field-email" )). toHaveAttribute ( "aria-required" , "true" );
});
test ( "invalid fields have aria-invalid after failed validation" , async ({ page }) => {
await page. goto ( BASE_URL );
await clickNext (page);
await expect (page. getByTestId ( "field-fullName" )). toHaveAttribute ( "aria-invalid" , "true" );
});
test ( "step indicator has aria-current on active step" , async ({ page }) => {
await page. goto ( BASE_URL );
await expect (page. getByTestId ( "step-1" )). toHaveAttribute ( "aria-current" , "step" );
});
});