Detalhe completo arquivo por arquivo do Formulário de Perfil de Arquiteto de Nuvem. Todo arquivo necessário para um formulário multi-etapas funcional dentro do Next.js App Router com ações de servidor React 19, validação alinhada a BDD, proteção de upload, UX de pendência e hooks prontos para Playwright.
Os arquivos Gherkin são a fonte da verdade. Cada regra de validação, comportamento da UI e mensagem de erro origina-se aqui. Desenvolvedores implementam código para satisfazer esses cenários.
# features/profile-form/04-validation.featureFeature: 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"
Pontos chave do código:
Background é executado antes de cada cenário - configura um usuário logado na página de criação
Cada Scenario mapeia exatamente para um caso de teste - o título descreve o comportamento esperado
Passos Given / When / Then são lidos em inglês simples para que não desenvolvedores possam revisar os critérios de aceitação
Strings de mensagem de erro (ex., "Nome completo é obrigatório") devem corresponder exatamente às mensagens do schema zod
O cenário .refine() (data de término após data de início) mostra validação entre campos em formato Gherkin
Cenários de upload de arquivo testam limites de tamanho e rejeição de tipo MIME como casos separados
A única fonte da verdade para toda a validação. Cada mensagem de erro zod corresponde exatamente a uma mensagem Gherkin Then they should see "...". Importado pelo cliente (zodResolver) e pela ação do servidor (safeParse).
// lib/schemas/architect-profile.tsimport { z } from "zod";// ── Shared constants ───────────────────────────────────const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MBconst 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>;
Pontos chave do código:
imageFileSchema é um refinamento zod reutilizável - valida tamanho (5MB) e tipo MIME, compartilhado por todos os campos de upload
Cada etapa tem seu próprio schema exportado (personalInfoSchema, experienceSchema, etc.) para que trigger() possa validar uma etapa por vez
.refine() em jobEntrySchema lida com validação entre campos (data de término > data de início) com um path personalizado direcionando para o campo específico
.or(z.literal("")) em linkedinUrl permite que o campo seja deixado em branco enquanto ainda valida o formato quando preenchido
STEP_SCHEMAS mapeia números de etapa para seus schemas - usado por StepNavigation para validar apenas os campos da etapa atual
architectProfileSchema mescla todos os schemas de etapa em um só - usado por zodResolver (cliente) e safeParse (servidor) para validação completa do formulário
z.infer<typeof ...> gera tipos TypeScript a partir de cada schema - única fonte da verdade tanto para validação quanto para tipos
Store Zustand é proprietária da máquina de estados da etapa. Sabe qual etapa está ativa, quais estão concluídas e se a navegação é permitida. Desacoplada dos dados do formulário (o react-hook-form é o proprietário disso).
A ação do servidor recebe FormData, valida no lado do servidor com o mesmo schema zod, valida os arquivos enviados independentemente, persiste os dados e redireciona. A assinatura de dois argumentos (prevState, formData) é exigida por 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 MIMEconst 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 layerasync 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" };}
Pontos chave do código:
"use server" marca isso como uma ação de servidor - ela é executada no servidor, nunca enviada para o bundle do cliente
serverFileSchema duplica a validação de arquivo no lado do servidor - nunca confie em verificações apenas do cliente (usuários podem ignorar o navegador)
Assinatura de dois argumentos (prevState, formData) é exigida por useActionState - prevState carrega o valor de retorno anterior
Tipo de retorno ProfileActionState tem fieldErrors: Record<string, string> - o passo de submissão mapeia isso de volta para react-hook-form e navega para a etapa correta
.omit({ profilePhoto: true, ... }) remove campos de arquivo do schema de texto - arquivos são validados separadamente, pois safeParse não consegue lidar com objetos File do FormData da mesma forma
formData.getAll("certifications") recupera múltiplos valores para o mesmo nome de campo de formulário - usado para campos de array (certificações, plataformas, especialidades)
redirect() lança internamente no Next.js - o bloco catch deve relançar NEXT_REDIRECT, ou o redirecionamento falha silenciosamente
Etapas 1-6 são comentários numerados - a ação segue um pipeline estrito: analisar → validar texto → validar arquivos → verificar duplicatas → enviar → criar registro → redirecionar
Cria a instância única de useForm compartilhada entre todas as etapas. O zodResolver conecta a validação zod ao react-hook-form. mode: "onBlur" fornece feedback em tempo real quando o usuário sai de um campo.
Roteia para o componente de etapa correto com base no estado do zustand. Envolve a etapa ativa em ajudantes de acessibilidade (anunciador de leitor de tela e foco automático).
// 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> );}
Pontos chave do código:
STEP_COMPONENTS é um lookup Record<number, React.ComponentType> - mapeia o número da etapa para o componente a ser renderizado
useProfileFormStore() lê currentStep do zustand - o assistente re-renderiza quando a etapa muda
StepIndicator + StepAnnouncer + AutoFocusStep + StepNavigation envolvem a etapa ativa - separação de preocupações entre UI de progresso, acessibilidade e navegação
data-testid="profile-form-wizard" fornece um hook Playwright para o contêiner do assistente
A barra de progresso. Mostra marcas de verificação para etapas concluídas, destaca a etapa atual e desabilita etapas futuras. Cada botão de etapa tem rótulos ARIA para leitores de tela.
Botões Voltar/Avançar/Enviar. "Avançar" valida os campos da etapa atual via trigger() antes de avançar. O mapeamento etapa-campo garante que apenas os campos da etapa ativa sejam verificados.
Wrapper de campo reutilizável que conecta aria-required, aria-invalid, aria-describedby e mensagens de erro role="alert". Usado por todos os componentes de etapa.
Array de campos dinâmico com useFieldArray. Corresponde ao Gherkin: "suporta entradas repetíveis", "Adicionar Outra Posição" e comportamento de "Remover".
togglePlatform / toggleSpecialty gerenciam manualmente o estado do array via setValue com { shouldValidate: true } - checkboxes não usam register() porque mapeiam para arrays, não valores individuais
watch("cloudPlatforms") renderiza dinamicamente grupos de rádio de proficiência - mostra apenas proficiência AWS/Azure/GCP quando a plataforma é selecionada
`${platform}Proficiency` as keyof ArchitectProfile calcula o nome do campo dinamicamente - ex., selecionar "aws" renderiza o grupo de rádio awsProficiency
RadioGroup com onValueChange sincroniza o nível selecionado de volta ao formulário via setValue
PLATFORMS usa as const - TypeScript restringe o value à união literal "aws" | "azure" | "gcp" correspondendo ao enum zod
Arrastar e soltar com miniaturas de pré-visualização, validação de tipo/tamanho no lado do cliente e um botão de remover por arquivo. Usa useController para sincronizar arquivos no estado do react-hook-form.
useController({ control, name }) sincroniza o estado do arquivo com o react-hook-form - diferente de register(), funciona com inputs não nativos (zonas de arrastar e soltar)
validateFile executa verificações no lado do cliente (tipo MIME, tamanho, contagem máxima) antes de adicionar arquivos - feedback rápido sem viagem de ida e volta ao servidor
URL.createObjectURL(file) cria miniaturas de pré-visualização - cada URL é rastreada no estado previews para limpeza
URL.revokeObjectURL(p.url) em removeFile evita vazamentos de memória - cada URL criada deve ser revogada quando não for mais necessária
isDragging estado alterna estilos de borda/fundo ao arrastar sobre - indicação visual de que a zona de soltar está ativa
isMaxReached desabilita a zona de soltar e o input de arquivo - impede exceder o limite maxFiles
role="button" + tabIndex={0} + onKeyDown torna a zona de soltar acessível por teclado - Enter/Espaço aciona o seletor de arquivos
O <input type="file"> oculto com className="sr-only" é o input de arquivo real - a zona de soltar visível delega cliques para ele via inputRef
Modo único vs. múltiplo: field.onChange(validFiles[0]) substitui para único, [...files, ...validFiles] anexa para múltiplo
Conecta valores do react-hook-form à ação do servidor via FormData. Usa useActionState para estado de pendência/erro. Mapeia erros de campo do servidor de volta para a etapa correta.
useActionState(createProfile, initialState) retorna [state, formAction, isPending] - conecta a ação do servidor à UI de pendência do React
FIELD_TO_STEP mapeia nomes de campo para números de etapa - quando o servidor retorna um erro de campo, a UI navega o usuário de volta para a etapa correta
O primeiro useEffect itera sobre state.fieldErrors e chama setError() para cada um - mapeia erros de validação do servidor de volta para o react-hook-form para que eles sejam exibidos inline
goToStep(targetStep) navega automaticamente para a etapa que contém o primeiro erro - o usuário não precisa encontrar manualmente qual etapa falhou
handleSubmit constrói manualmente FormData a partir de getValues() - conecta o estado do react-hook-form à entrada FormData esperada pela ação do servidor
formData.append() (não set) é usado para campos de array - certificações, plataformas, especialidades e múltiplos uploads de arquivo
JSON.stringify(values.jobs) serializa o array de empregos - objetos aninhados complexos não podem ser enviados como entradas FormData individuais
isPending desabilita o botão de envio e exibe um spinner Loader2 - impede o envio duplo
ReviewCard componente renderiza um cartão de resumo por etapa com um botão "Editar" - permite ao usuário pular para qualquer etapa antes de enviar
querySelector com um seletor complexo encontra o primeiro input focável, não oculto e não desabilitado - funciona para inputs de texto, selects e textareas
useEffect depende de currentStep - dispara toda vez que a etapa muda, movendo o foco para o primeiro campo da nova etapa
containerRef envolve o conteúdo da etapa - escopa a consulta apenas ao DOM da etapa atual, não à página inteira
Funções auxiliares (fillStep1, fillStep2, etc.) espelham os passos Given do Gherkin - configuração reutilizável para cada teste
test.describe grupos mapeiam para nomes de Feature Gherkin - "Navegação Multi-Etapas", "Validação", "Uploads de Arquivo", etc.
test.beforeEach navega para a página - corresponde ao bloco Background do Gherkin
getByTestId() seletores visam atributos data-testid - desacoplados de classes CSS ou conteúdo de texto
toHaveAttribute("data-status", "completed") afirma o estado do indicador de etapa - testa o mesmo atributo que o CSS usa para estilização
toHaveValue("Jane Doe") após navegar para trás verifica a persistência dos dados - corresponde ao cenário Gherkin "navegar para trás preserva os dados"
setInputFiles() com um Buffer.alloc(6 * 1024 * 1024) cria um arquivo sintético superdimensionado - nenhum arquivo real necessário para o teste de rejeição
fillStep1 → clickNext → fillStep2 → clickNext → ... encadeamentos no teste de upload navegam para a etapa 5 - cada etapa deve passar na validação antes de avançar
Asserções toBeVisible() em mensagens de erro correspondem ao Gherkin Then they should see "..." - o teste verifica a string de erro exata visível pelo usuário
Testes de acessibilidade verificam aria-required, aria-invalid e aria-current - garante que os contratos ARIA documentados na especificação Gherkin sejam implementados
data-testid ausente -- Testes Playwright dependem de atributos data-testid. Todo elemento interativo precisa de um. Adicione-os quando criar um componente, não depois.
Schema importando módulos do servidor -- lib/schemas/architect-profile.ts é compartilhado entre cliente e servidor. Ele não deve importar nada de arquivos "use server", next/headers, ou clientes de banco de dados.
Derivação de índice useFieldArray -- quando você remove uma entrada de emprego, todos os índices subsequentes mudam. Use field.id (de useFieldArray) como chave React, não o índice do array.
Pré-visualizações de arquivo não limpas -- cada URL.createObjectURL deve ter um revokeObjectURL quando o arquivo é removido. O componente FileUpload cuida disso, mas se você construir UI de upload personalizada, rastreie suas URLs.
redirect() em ações de servidor -- redirect() do Next.js lança um erro interno. Seu try/catch na ação do servidor deve relançá-lo, ou o redirecionamento falha silenciosamente.
Upload de arquivo Playwright -- use page.locator('input[type=file]').setInputFiles() com um caminho de arquivo real ou um Buffer. A zona de soltar visual não é um input de arquivo real, então mire no <input> oculto dentro dela.