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.
The Filetree
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
Where Each Responsibility Lives
| Responsibility | File(s) | Why it lives there |
|---|---|---|
| BDD Spec | features/**/*.feature | Gherkin files stakeholders read and approve before coding |
| Validation Rules | lib/schemas/architect-profile.ts | Shared zod schema -- imported by both client (zodResolver) and server (safeParse) |
| Form UI | components/profile-form/*-step.tsx | One Client Component per step, each uses useFormContext |
| Step Logic | stores/profile-form-store.ts | Zustand store tracks current step, completed steps, navigation rules |
| Stepper Control | step-indicator.tsx, step-navigation.tsx | UI chrome for the wizard -- progress bar and Back/Next buttons |
| Submission Logic | lib/actions/create-profile.ts + submit-step.tsx | Server action handles data, submit-step bridges react-hook-form to useActionState |
| File Upload | file-upload.tsx + server action file validation | Client: drag/drop + preview + size/type check. Server: duplicate MIME validation |
| Playwright Tests | e2e/profile-form.spec.ts | E2E tests that map directly to Gherkin scenarios |
File-by-File Code
1. BDD Spec -- features/profile-form/04-validation.feature
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 "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"Key code points:
Backgroundruns before every scenario — sets up a logged-in user on the create page- Each
Scenariomaps to exactly one test case — the title describes the expected behavior Given/When/Thensteps 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
2. Validation Rules -- lib/schemas/architect-profile.ts
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 = 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>;Key code points:
imageFileSchemais 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.) sotrigger()can validate one step at a time .refine()onjobEntrySchemahandles cross-field validation (end date > start date) with a custompathtargeting the specific field.or(z.literal(""))onlinkedinUrlallows the field to be left empty while still validating format when filledSTEP_SCHEMASmaps step numbers to their schema — used byStepNavigationto validate only the current step's fieldsarchitectProfileSchemamerges all step schemas into one — used byzodResolver(client) andsafeParse(server) for full-form validationz.infer<typeof ...>generates TypeScript types from each schema — single source of truth for both validation and types
3. Step Logic -- stores/profile-form-store.ts
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: "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() }),
}));Key code points:
create<ProfileFormState>()defines a typed zustand store — state + actions in one objectcompletedStepsuses aSet<number>for O(1) lookup of whether a step is donegoToNext()clones the Set before mutating (new Set(state.completedSteps)) — zustand requires immutable updatesgoToPrevious()does not mark the current step as completed — going back is non-destructivecanNavigateTo()enforces linear wizard flow: you can revisit completed steps or advance one step ahead, but not skipgetStepStatus()returns"current" | "completed" | "upcoming"— drives the visual state ofStepIndicatorreset()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-formowns field values
4. Submission Logic -- lib/actions/create-profile.ts
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)
.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" };
}Key code points:
"use server"marks this as a server action — it runs on the server, never shipped to the client bundleserverFileSchemaduplicates file validation server-side — never trust client-only checks (users can bypass the browser)(prevState, formData)two-argument signature is required byuseActionState—prevStatecarries the previous return valueProfileActionStatereturn type hasfieldErrors: Record<string, string>— the submit step maps these back toreact-hook-formand navigates to the correct step.omit({ profilePhoto: true, ... })strips file fields from the text schema — files are validated separately sincesafeParsecan't handleFileobjects fromFormDatathe same wayformData.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-throwNEXT_REDIRECTor 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
5. Form UI Root -- components/profile-form/profile-form-provider.tsx
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<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>;
}Key code points:
useForm<ArchitectProfile>()creates a single form instance typed to the full schema — shared across all steps via contextzodResolver(architectProfileSchema)connects zod validation to react-hook-form — validation errors auto-populateformState.errorsmode: "onBlur"validates fields when the user tabs away — gives real-time feedback without interrupting typingFormProviderwraps all children — any nested component can calluseFormContext()to access form state without prop drillingdefaultValuesseeds the form with empty/zero values — prevents uncontrolled-to-controlled input warnings
6. Stepper Control -- components/profile-form/profile-form-wizard.tsx
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";
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>
);
}Key code points:
STEP_COMPONENTSis aRecord<number, React.ComponentType>lookup — maps step number to the component to renderuseProfileFormStore()readscurrentStepfrom zustand — the wizard re-renders when the step changesStepIndicator+StepAnnouncer+AutoFocusStep+StepNavigationwrap the active step — separation of concerns between progress UI, accessibility, and navigationdata-testid="profile-form-wizard"provides a Playwright hook for the wizard container
7. Stepper Control -- components/profile-form/step-indicator.tsx
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, 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>
);
}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 technologyaria-current="step"on the active button tells screen readers which step the user is onaria-labelon 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 stylingcanNavigateTo(step.id)disables forward steps — prevents skipping ahead in the wizard- Completed steps show a
<Check>icon witharia-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
8. Stepper Control -- components/profile-form/step-navigation.tsx
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: 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>
);
}Key code points:
STEP_FIELDSmaps each step number to its field names —trigger(fields)validates only the current step, not the entire formhandleNextis async becausetrigger()returns aPromise<boolean>— it runs zod validation on just the listed fieldsgoToNext()only fires if validation passes — the form cannot advance with errors- On the last step, the button changes from
type="button"(Next) totype="submit"(Submit Profile) — triggers the server action <div />placeholder whenisFirstStepkeeps the flex layout aligned — Back button space is preserved
9. Form UI -- components/profile-form/accessible-field.tsx
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 } 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>
);
}Key code points:
useFormContext<ArchitectProfile>()accesses the shared form — no props needed, the provider handles itregister(name)connects the input to react-hook-form — handles onChange, onBlur, ref, and valuearia-required={required}tells screen readers the field is mandatoryaria-invalid={!!error}flips totruewhen 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 focusedrole="alert"on the error<p>makes it a live region — screen readers announce the error immediately when it appearsdata-testid={field-$}provides consistent Playwright selectors across all fields
10. Form UI Step 1 -- components/profile-form/personal-info-step.tsx
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>
<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>
);
}Key code points:
<fieldset>with<legend>groups related fields semantically — screen readers announce "Personal Information" when entering the groupAccessibleFieldhandles all ARIA wiring — each step component stays simple and declarativedata-testid="step-personal-info"enables Playwright to assert which step is visible- The
requiredprop onAccessibleFieldadds botharia-requiredand the visual asterisk (*)
11. Form UI Step 2 -- components/profile-form/experience-step.tsx
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";
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>
);
}Key code points:
watch("certifications")subscribes to the array value — re-renders the component when certifications changesetValue("certifications", updated, { shouldValidate: true })writes the toggled array back and triggers validationCERTIFICATIONSis aconstarray — keeps options centralized, easy to add/removeTextareawitharia-describedbyconditionally points to eitherbio-errororbio-counter— screen readers get the right context- Character counter changes color to
text-destructivewhen over 1000 — visual warning before validation fires {certifications.length} selectedshows a count below checkboxes — quick feedback without requiring the user to count
12. Form UI Step 3 -- components/profile-form/job-history-step.tsx
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";
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>
);
}Key code points:
useFieldArray({ control, name: "jobs" })manages the dynamic array — providesfields,append, andremovefield.id(notindex) is used as the Reactkey— stable across additions and removals, prevents input value ghostingMAX_JOBS = 10caps the array — the "Add Another Position" button hides when the limit is reachedfields.length > 1guards 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 checkederrors.jobs?.[index]?.endDateuses optional chaining for nested field array errors — the error path includes the array indexerrors.jobs?.rootdisplays the array-level error ("Add at least one position") — separate from per-entry errorsappend({...})adds a new blank entry with all required fields initialized — prevents uncontrolled input warnings
13. Form UI Step 4 -- components/profile-form/skills-step.tsx
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 "@/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>
);
}Key code points:
togglePlatform/toggleSpecialtymanually manage array state viasetValuewith{ shouldValidate: true }— checkboxes don't useregister()because they map to arrays, not individual valueswatch("cloudPlatforms")dynamically renders proficiency radio groups — only shows AWS/Azure/GCP proficiency when the platform is selected`${platform}Proficiency` as keyof ArchitectProfilecomputes the field name dynamically — e.g., selecting "aws" renders theawsProficiencyradio groupRadioGroupwithonValueChangesyncs the selected level back to the form viasetValuePLATFORMSusesas const— TypeScript narrows thevalueto the literal union"aws" | "azure" | "gcp"matching the zod enum
14. File Upload -- components/profile-form/file-upload.tsx
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";
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>
);
}Key code points:
useController({ control, name })syncs file state with react-hook-form — unlikeregister(), it works with non-native inputs (drag/drop zones)validateFileruns client-side checks (MIME type, size, max count) before adding files — fast feedback without a server round-tripURL.createObjectURL(file)creates preview thumbnails — each URL is tracked inpreviewsstate for cleanupURL.revokeObjectURL(p.url)inremoveFileprevents memory leaks — every created URL must be revoked when no longer neededisDraggingstate toggles border/background styling on drag-over — visual cue that the drop zone is activeisMaxReacheddisables the drop zone and file input — prevents exceeding themaxFileslimitrole="button"+tabIndex={0}+onKeyDownmakes the drop zone keyboard-accessible — Enter/Space triggers the file picker- The hidden
<input type="file">withclassName="sr-only"is the actual file input — the visible drop zone delegates clicks to it viainputRef - Single vs. multiple mode:
field.onChange(validFiles[0])replaces for single,[...files, ...validFiles]appends for multiple
15. File Upload Layout -- components/profile-form/uploads-step.tsx
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>
<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>
);
}Key code points:
- Composes three
FileUploadinstances with different configs — single (profile photo) vs. multiple (diagrams, screenshots) maxFiles={10}matches the zod schema'sMAX_DIAGRAM_COUNT— client and server limits stay in sync
16. Submission UX -- components/profile-form/submit-step.tsx
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 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>
);
}Key code points:
useActionState(createProfile, initialState)returns[state, formAction, isPending]— bridges the server action to React's pending UIFIELD_TO_STEPmaps field names to step numbers — when the server returns a field error, the UI navigates the user back to the correct step- The first
useEffectiteratesstate.fieldErrorsand callssetError()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 failedhandleSubmitmanually buildsFormDatafromgetValues()— bridges react-hook-form's state to the server action's expectedFormDatainputformData.append()(notset) is used for array fields — certifications, platforms, specialties, and multiple file uploadsJSON.stringify(values.jobs)serializes the job array — complex nested objects can't be sent as individualFormDataentriesisPendingdisables the submit button and shows aLoader2spinner — prevents double submissionReviewCardcomponent renders a summary card per step with an "Edit" button — lets the user jump back to any step before submitting
17. Accessibility -- components/profile-form/step-announcer.tsx
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 = 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"
/>
);
}Key code points:
role="status"+aria-live="polite"creates a live region — screen readers announce content changes without interrupting the current readingaria-atomic="true"ensures the entire announcement is read, not just the changed textclassName="sr-only"hides the div visually — it exists only for assistive technologypreviousStepref prevents re-announcing when the component re-renders without a step changeannouncerRef.current.textContent = ...updates the text imperatively — React state changes would cause unnecessary re-renders
18. Accessibility -- components/profile-form/auto-focus-step.tsx
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 { 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>;
}Key code points:
querySelectorwith a complex selector finds the first focusable, non-hidden, non-disabled input — works for text inputs, selects, and textareasuseEffectdepends oncurrentStep— fires every time the step changes, moving focus to the new step's first fieldcontainerRefwraps the step content — scopes the query to only the current step's DOM, not the entire page
19. Page Entry -- app/profile/create/page.tsx
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="text-2xl font-bold mb-6">Create Your Profile</h1>
<ProfileFormProvider>
<ProfileFormWizard />
</ProfileFormProvider>
</main>
);
}Key code points:
- This is a Server Component (no
"use client") — it renders on the server and sends HTML to the client ProfileFormProviderwrapsProfileFormWizard— 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/createmaps toapp/profile/create/page.tsx— Next.js App Router file-based routing
20. Playwright E2E -- e2e/profile-form.spec.ts
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) {
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");
});
});Key code points:
- Helper functions (
fillStep1,fillStep2, etc.) mirror the GherkinGivensteps — reusable setup for each test test.describegroups map to GherkinFeaturenames — "Multi-Step Navigation", "Validation", "File Uploads", etc.test.beforeEachnavigates to the page — matches the GherkinBackgroundblockgetByTestId()selectors targetdata-testidattributes — decoupled from CSS classes or text contenttoHaveAttribute("data-status", "completed")asserts step indicator state — tests the same attribute the CSS uses for stylingtoHaveValue("Jane Doe")after navigating back verifies data persistence — matches the Gherkin scenario "navigating back preserves data"setInputFiles()with aBuffer.alloc(6 * 1024 * 1024)creates a synthetic oversized file — no real file needed for the rejection testfillStep1 → clickNext → fillStep2 → clickNext → ...chains in the upload test navigate to step 5 — each step must pass validation before advancingtoBeVisible()assertions on error messages match GherkinThen they should see "..."— the test verifies the exact user-visible error string- Accessibility tests verify
aria-required,aria-invalid, andaria-current— ensures the ARIA contracts documented in the Gherkin spec are implemented
Responsibility Flow Diagram
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
└──────────────┘
Recipe
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-step |
| Add a file upload field | uploadsSchema in schema + new <FileUpload> in uploads-step |
| Write a new E2E test | e2e/profile-form.spec.ts using data-testid selectors |
| Change acceptance criteria | features/profile-form/*.feature first, then update code to match |
Gotchas
-
Missing
data-testid-- Playwright tests rely ondata-testidattributes. Every interactive element needs one. Add them when you create a component, not later. -
Schema file importing server modules --
lib/schemas/architect-profile.tsis shared between client and server. It must not import anything from"use server"files,next/headers, or database clients. -
useFieldArrayindex drift -- when you remove a job entry, all subsequent indexes shift. Usefield.id(from useFieldArray) as the React key, not the array index. -
File previews not cleaned up -- every
URL.createObjectURLmust have arevokeObjectURLwhen the file is removed. TheFileUploadcomponent handles this, but if you build custom upload UI, track your URLs. -
redirect()in server actions -- Next.jsredirect()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 aBuffer. The visual drop zone is not a real file input, so target the hidden<input>inside it.
Alternatives
| 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) |
Related
- Gherkin Form Decision Checklist -- the 10-question checklist that defines WHAT to build
- Gherkin to Code -- how each Gherkin scenario maps to code patterns
- Testing Forms -- unit testing forms with Testing Library
- Playwright Patterns -- Playwright best practices
- Forms & Validation -- react-hook-form and zod deep dives