Ejemplo de árbol de archivos
Desglose completo archivo por archivo del formulario de perfil de arquitecto cloud. Todos los archivos necesarios para un formulario multi-paso funcional dentro de Next.js App Router con React 19 server actions, validación alineada con BDD, protección de subidas, UX de pendiente y hooks de prueba listos para Playwright.
El árbol de archivos
cloud-architect-profile/
├── features/ ← Especificación BDD
│ ├── 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 ← Reglas de validación (zod compartido)
│ └── actions/
│ └── create-profile.ts ← Lógica de envío (server action)
├── stores/
│ └── profile-form-store.ts ← Lógica de pasos (zustand)
├── components/
│ └── profile-form/
│ ├── profile-form-provider.tsx ← UI del formulario (raíz de react-hook-form)
│ ├── profile-form-wizard.tsx ← Control del stepper (enrutador de pasos)
│ ├── step-indicator.tsx ← Control del stepper (barra de progreso)
│ ├── step-navigation.tsx ← Control del stepper (Atrás/Siguiente)
│ ├── step-announcer.tsx ← Accesibilidad (lector de pantalla)
│ ├── auto-focus-step.tsx ← Accesibilidad (gestión del foco)
│ ├── accessible-field.tsx ← UI del formulario (campo reutilizable)
│ ├── personal-info-step.tsx ← UI del formulario (paso 1)
│ ├── experience-step.tsx ← UI del formulario (paso 2)
│ ├── job-history-step.tsx ← UI del formulario (paso 3, campos dinámicos)
│ ├── skills-step.tsx ← UI del formulario (paso 4)
│ ├── uploads-step.tsx ← Subida de archivos (layout del paso 5)
│ ├── file-upload.tsx ← Subida de archivos (arrastrar/soltar/vista previa)
│ └── submit-step.tsx ← Lógica de envío (revisión + useActionState)
├── app/
│ └── profile/
│ └── create/
│ └── page.tsx ← Punto de entrada de la página
└── e2e/
└── profile-form.spec.ts ← Pruebas E2E de Playwright
Dónde vive cada responsabilidad
| Responsabilidad | Archivo(s) | Por qué vive ahí |
|---|---|---|
| Especificación BDD | features/**/*.feature | Archivos Gherkin que los stakeholders leen y aprueban antes de codificar |
| Reglas de validación | lib/schemas/architect-profile.ts | Esquema zod compartido — importado por el cliente (zodResolver) y el servidor (safeParse) |
| UI del formulario | components/profile-form/*-step.tsx | Un Client Component por paso, cada uno usa useFormContext |
| Lógica de pasos | stores/profile-form-store.ts | El store de Zustand rastrea el paso actual, los pasos completados y las reglas de navegación |
| Control del stepper | step-indicator.tsx, step-navigation.tsx | UI del asistente — barra de progreso y botones Atrás/Siguiente |
| Lógica de envío | lib/actions/create-profile.ts + submit-step.tsx | La server action maneja los datos; submit-step conecta react-hook-form con useActionState |
| Subida de archivos | file-upload.tsx + validación de archivos en la server action | Cliente: arrastrar/soltar + vista previa + comprobación de tamaño/tipo. Servidor: validación MIME duplicada |
| Pruebas Playwright | e2e/profile-form.spec.ts | Pruebas E2E que se mapean directamente a los escenarios Gherkin |
Código archivo por archivo
1. Especificación BDD -- features/profile-form/04-validation.feature
Los archivos Gherkin son la fuente de verdad. Cada regla de validación, comportamiento de UI y mensaje de error se origina aquí. Los desarrolladores implementan código para satisfacer estos escenarios.
# 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"Puntos clave del código:
Backgroundse ejecuta antes de cada escenario — configura un usuario con sesión iniciada en la página de creación- Cada
Scenariose mapea exactamente a un caso de prueba — el título describe el comportamiento esperado - Los pasos
Given/When/Thense leen como lenguaje natural para que los no desarrolladores puedan revisar los criterios de aceptación - Las cadenas de mensajes de error (p. ej.,
"El nombre completo es obligatorio") deben coincidir exactamente con los mensajes del esquema zod - El escenario
.refine()(fecha de fin posterior a la de inicio) muestra la validación entre campos en forma Gherkin - Los escenarios de subida de archivos prueban los límites de tamaño y el rechazo de tipo MIME como casos separados
2. Reglas de validación -- lib/schemas/architect-profile.ts
La única fuente de verdad para toda la validación. Cada mensaje de error de zod coincide exactamente con un Then debería ver "..." de Gherkin. Importado por el cliente (zodResolver) y la server action (safeParse).
// lib/schemas/architect-profile.ts
import { z } from "zod";
// ── Constantes compartidas ───────────────────────────────────
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_DIAGRAM_COUNT = 10;
// ── Validador de archivos reutilizable ────────────────────────────
const imageFileSchema = z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, "El archivo debe pesar menos de 5 MB")
.refine(
(file) => ACCEPTED_IMAGE_TYPES.includes(file.type),
"Solo se aceptan archivos JPEG, PNG y WebP"
);
// ── Paso 1: Información personal ──────────────────────────────
export const personalInfoSchema = z.object({
fullName: z
.string()
.min(1, "El nombre completo es obligatorio")
.min(2, "El nombre debe tener al menos 2 caracteres"),
email: z
.string()
.min(1, "El correo electrónico es obligatorio")
.email("Introduce una dirección de correo válida"),
phone: z.string().optional(),
linkedinUrl: z
.string()
.url("Debe ser una URL válida")
.refine(
(url) => url.includes("linkedin.com/"),
"Debe ser una URL de perfil de LinkedIn válida"
)
.or(z.literal("")),
});
// ── Paso 2: Experiencia ─────────────────────────────────
export const experienceSchema = z.object({
yearsOfExperience: z
.number({ invalid_type_error: "Debe ser un número" })
.int("Debe ser un número entero")
.min(0, "Debe estar entre 0 y 50 años")
.max(50, "Debe estar entre 0 y 50 años"),
currentRole: z.string().min(1, "El rol actual es obligatorio"),
certifications: z.array(z.string()),
bio: z.string().max(1000, "La biografía debe tener 1000 caracteres o menos").optional(),
});
// ── Paso 3: Historial laboral ────────────────────────────────
const jobEntrySchema = z
.object({
company: z.string().min(1, "El nombre de la empresa es obligatorio"),
role: z.string().min(1, "El rol es obligatorio"),
startDate: z.string().min(1, "La fecha de inicio es obligatoria"),
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: "La fecha de fin debe ser posterior a la de inicio", path: ["endDate"] }
);
export const jobHistorySchema = z.object({
jobs: z.array(jobEntrySchema).min(1, "Añade al menos un puesto"),
});
// ── Paso 4: Habilidades ─────────────────────────────────────
export const skillsSchema = z.object({
cloudPlatforms: z
.array(z.enum(["aws", "azure", "gcp"]))
.min(1, "Selecciona al menos una plataforma cloud"),
specialties: z.array(z.string()).min(1, "Selecciona al menos una especialidad"),
awsProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
azureProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
gcpProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
});
// ── Paso 5: Subidas ────────────────────────────────────
export const uploadsSchema = z.object({
profilePhoto: imageFileSchema.optional(),
architectureDiagrams: z
.array(imageFileSchema)
.max(MAX_DIAGRAM_COUNT, `Máximo ${MAX_DIAGRAM_COUNT} archivos permitidos`)
.optional(),
siteScreenshots: z
.array(imageFileSchema)
.max(MAX_DIAGRAM_COUNT, `Máximo ${MAX_DIAGRAM_COUNT} archivos permitidos`)
.optional(),
});
// ── Esquemas por paso (usados por StepNavigation.trigger()) ──
export const STEP_SCHEMAS = {
1: personalInfoSchema,
2: experienceSchema,
3: jobHistorySchema,
4: skillsSchema,
5: uploadsSchema,
} as const;
// ── Esquema combinado (usado por zodResolver + server action) ──
export const architectProfileSchema = personalInfoSchema
.merge(experienceSchema)
.merge(jobHistorySchema)
.merge(skillsSchema)
.merge(uploadsSchema);
// ── Tipos inferidos ─────────────────────────────────────
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>;Puntos clave del código:
imageFileSchemaes un refinamiento zod reutilizable — valida tanto el tamaño (5MB) como el tipo MIME, compartido por todos los campos de subida- Cada paso tiene su propio esquema exportado (
personalInfoSchema,experienceSchema, etc.) para quetrigger()pueda validar un paso a la vez .refine()enjobEntrySchemamaneja la validación entre campos (fecha de fin > fecha de inicio) con unpathpersonalizado que apunta al campo específico.or(z.literal(""))enlinkedinUrlpermite dejar el campo vacío mientras valida el formato cuando se rellenaSTEP_SCHEMASmapea los números de paso a su esquema — usado porStepNavigationpara validar solo los campos del paso actualarchitectProfileSchemafusiona todos los esquemas de paso en uno — usado porzodResolver(cliente) ysafeParse(servidor) para la validación del formulario completoz.infer<typeof ...>genera tipos TypeScript a partir de cada esquema — única fuente de verdad tanto para validación como para tipos
3. Lógica de pasos -- stores/profile-form-store.ts
El store de Zustand posee la máquina de estados de los pasos. Sabe qué paso está activo, cuáles están completados y si la navegación está permitida. Desacoplado de los datos del formulario (react-hook-form posee eso).
// stores/profile-form-store.ts
import { create } from "zustand";
export type StepStatus = "upcoming" | "current" | "completed";
export const STEPS = [
{ id: 1, label: "Información personal" },
{ id: 2, label: "Experiencia" },
{ id: 3, label: "Historial laboral" },
{ id: 4, label: "Habilidades" },
{ id: 5, label: "Subidas" },
{ id: 6, label: "Revisar y enviar" },
] 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() }),
}));Puntos clave del código:
create<ProfileFormState>()define un store de zustand tipado — state + acciones en un solo objetocompletedStepsusa unSet<number>para búsqueda O(1) de si un paso está hechogoToNext()clona el Set antes de mutar (new Set(state.completedSteps)) — zustand requiere actualizaciones inmutablesgoToPrevious()no marca el paso actual como completado — retroceder es no destructivocanNavigateTo()impone un flujo lineal del asistente: puedes revisitar pasos completados o avanzar un paso, pero no saltargetStepStatus()devuelve"current" | "completed" | "upcoming"— impulsa el estado visual deStepIndicatorreset()borra todo el progreso — usado tras un envío exitoso o cuando el usuario quiere empezar de nuevo- El store está desacoplado de los datos del formulario — zustand posee el estado de navegación,
react-hook-formposee los valores de los campos
4. Lógica de envío -- lib/actions/create-profile.ts
La server action recibe FormData, valida en el servidor con el mismo esquema zod, valida los archivos subidos de forma independiente, persiste los datos y redirige. La firma de dos argumentos (prevState, formData) es requerida 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";
// Esquema de archivos solo del servidor -- más estricto, comprueba el MIME real
const serverFileSchema = z
.instanceof(File)
.refine((file) => file.size <= 5 * 1024 * 1024, "El archivo debe pesar menos de 5 MB")
.refine(
(file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
"Tipo de archivo no válido"
);
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. Parsear campos de texto desde 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. Validar campos de texto (mismo esquema que el cliente) ──
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. Validar archivos en el servidor (validación dual) ──
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. Comprobar correo duplicado ─────────────────
const existingProfile = await findProfileByEmail(textResult.data.email);
if (existingProfile) {
return {
success: false,
message: "",
fieldErrors: { email: "Este correo electrónico ya está registrado" },
};
}
// ── 5. Subir archivos al almacenamiento ───────────────────
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. Crear registro de perfil ─────────────────────
const profile = await createProfileRecord({
...textResult.data,
photoUrl,
diagramUrls,
screenshotUrls,
});
redirect(`/profile/${profile.id}`);
} catch (error) {
// redirect() lanza internamente -- relanzarlo
if (error instanceof Error && error.message === "NEXT_REDIRECT") {
throw error;
}
console.error("Profile creation failed:", error);
return {
success: false,
message: "Algo salió mal. Inténtalo de nuevo.",
fieldErrors: {},
};
}
}
// Sustituye con tu capa real de BD/almacenamiento
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" };
}Puntos clave del código:
"use server"marca esto como server action — se ejecuta en el servidor, nunca se envía al bundle del clienteserverFileSchemaduplica la validación de archivos en el servidor — nunca confíes solo en comprobaciones del cliente (los usuarios pueden omitir el navegador)- La firma de dos argumentos
(prevState, formData)es requerida poruseActionState—prevStatetransporta el valor de retorno anterior - El tipo de retorno
ProfileActionStatetienefieldErrors: Record<string, string>— el paso de envío los mapea de vuelta areact-hook-formy navega al paso correcto .omit({ profilePhoto: true, ... })elimina los campos de archivo del esquema de texto — los archivos se validan por separado porquesafeParseno puede manejar objetosFiledeFormDatade la misma formaformData.getAll("certifications")recupera múltiples valores para el mismo nombre de campo — usado para campos array (certificaciones, plataformas, especialidades)redirect()lanza internamente en Next.js — el bloque catch debe relanzarNEXT_REDIRECTo la redirección falla en silencio- Los pasos 1-6 son comentarios numerados — la acción sigue un pipeline estricto: parsear → validar texto → validar archivos → comprobar duplicados → subir → crear registro → redirigir
5. Raíz de la UI del formulario -- components/profile-form/profile-form-provider.tsx
Crea la única instancia de useForm compartida entre todos los pasos. El zodResolver conecta la validación zod con react-hook-form. mode: "onBlur" da retroalimentación en tiempo real cuando el usuario sale de un campo con Tab.
// 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>;
}Puntos clave del código:
useForm<ArchitectProfile>()crea una única instancia de formulario tipada al esquema completo — compartida entre todos los pasos vía contextozodResolver(architectProfileSchema)conecta la validación zod con react-hook-form — los errores de validación rellenan automáticamenteformState.errorsmode: "onBlur"valida los campos cuando el usuario sale con Tab — da retroalimentación en tiempo real sin interrumpir la escrituraFormProviderenvuelve todos los hijos — cualquier componente anidado puede llamar auseFormContext()para acceder al estado del formulario sin prop drillingdefaultValuesinicializa el formulario con valores vacíos/cero — evita advertencias de inputs no controlados a controlados
6. Control del stepper -- components/profile-form/profile-form-wizard.tsx
Enruta al componente de paso correcto según el estado de zustand. Envuelve el paso activo en ayudantes de accesibilidad (anunciador para lector de pantalla y auto-foco).
// 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>
);
}Puntos clave del código:
STEP_COMPONENTSes una búsquedaRecord<number, React.ComponentType>— mapea el número de paso al componente a renderizaruseProfileFormStore()leecurrentStepde zustand — el asistente se re-renderiza cuando cambia el pasoStepIndicator+StepAnnouncer+AutoFocusStep+StepNavigationenvuelven el paso activo — separación de responsabilidades entre UI de progreso, accesibilidad y navegacióndata-testid="profile-form-wizard"proporciona un hook de Playwright para el contenedor del asistente
7. Control del stepper -- components/profile-form/step-indicator.tsx
La barra de progreso. Muestra marcas de verificación para los pasos completados, resalta el paso actual y deshabilita los pasos futuros. Cada botón de paso tiene etiquetas ARIA para lectores de pantalla.
// 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="Progreso del formulario" data-testid="step-indicator">
<p className="sr-only">
Paso {currentStep} de {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={`Paso ${step.id}: ${step.label}${
status === "completed" ? " (completado)" : ""
}`}
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>
);
}Puntos clave del código:
<nav aria-label="Progreso del formulario">convierte la barra de pasos en un landmark para lectores de pantalla<p className="sr-only">anuncia "Paso X de Y" — visible solo para tecnología de asistenciaaria-current="step"en el botón activo indica a los lectores de pantalla en qué paso está el usuarioaria-labelen cada botón incluye el número de paso, la etiqueta y el estado de completado (p. ej., "Paso 1: Información personal (completado)")- El atributo
data-status={status}habilita tanto aserciones de Playwright (toHaveAttribute) como estilos CSS canNavigateTo(step.id)deshabilita los pasos futuros — evita saltar adelante en el asistente- Los pasos completados muestran un icono
<Check>conaria-hidden="true"— decorativo, el aria-label ya transmite el estado - Las líneas conectoras (
h-px w-8) entre pasos cambian de color según la completitud — indicador visual de progreso
8. Control del stepper -- components/profile-form/step-navigation.tsx
Botones Atrás/Siguiente/Enviar. "Siguiente" valida los campos del paso actual vía trigger() antes de avanzar. El mapeo paso-a-campo garantiza que solo se comprueben los campos del paso activo.
// 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"
>
Atrás
</Button>
) : (
<div />
)}
{!isLastStep ? (
<Button type="button" onClick={handleNext} data-testid="btn-next">
Siguiente
</Button>
) : (
<Button type="submit" data-testid="btn-submit">
Enviar perfil
</Button>
)}
</div>
);
}Puntos clave del código:
STEP_FIELDSmapea cada número de paso a sus nombres de campo —trigger(fields)valida solo el paso actual, no el formulario completohandleNextes async porquetrigger()devuelve unPromise<boolean>— ejecuta la validación zod solo en los campos listadosgoToNext()solo se dispara si la validación pasa — el formulario no puede avanzar con errores- En el último paso, el botón cambia de
type="button"(Siguiente) atype="submit"(Enviar perfil) — dispara la server action - El
<div />placeholder cuandoisFirstStepmantiene alineado el layout flex — se preserva el espacio del botón Atrás
9. UI del formulario -- components/profile-form/accessible-field.tsx
Wrapper de campo reutilizable que conecta aria-required, aria-invalid, aria-describedby y mensajes de error con role="alert". Usado por cada componente de paso.
// 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>
);
}Puntos clave del código:
useFormContext<ArchitectProfile>()accede al formulario compartido — no necesita props, el provider lo manejaregister(name)conecta el input con react-hook-form — maneja onChange, onBlur, ref y valuearia-required={required}indica a los lectores de pantalla que el campo es obligatorioaria-invalid={!!error}pasa atruecuando falla la validación — los lectores de pantalla anuncian "entrada no válida"aria-describedby={error ? errorId : undefined}vincula el input con su mensaje de error — los lectores de pantalla leen el error cuando el campo tiene focorole="alert"en el<p>de error lo convierte en región viva — los lectores de pantalla anuncian el error inmediatamente al aparecerdata-testid={field-$}proporciona selectores consistentes de Playwright en todos los campos
10. UI del formulario paso 1 -- components/profile-form/personal-info-step.tsx
Usa AccessibleField para cada input. Coincide con Gherkin: "el paso 1 debería renderizar con campos de nombre, correo, teléfono y LinkedIn."
// 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">Información personal</legend>
<div className="space-y-4">
<AccessibleField name="fullName" label="Nombre completo" required />
<AccessibleField name="email" label="Correo electrónico" type="email" required />
<AccessibleField name="phone" label="Teléfono" type="tel" />
<AccessibleField
name="linkedinUrl"
label="URL de LinkedIn"
type="url"
placeholder="https://linkedin.com/in/your-profile"
/>
</div>
</fieldset>
);
}Puntos clave del código:
<fieldset>con<legend>agrupa campos relacionados semánticamente — los lectores de pantalla anuncian "Información personal" al entrar en el grupoAccessibleFieldmaneja todo el cableado ARIA — cada componente de paso permanece simple y declarativodata-testid="step-personal-info"permite a Playwright afirmar qué paso es visible- La prop
requiredenAccessibleFieldañade tantoaria-requiredcomo el asterisco visual (*)
11. UI del formulario paso 2 -- components/profile-form/experience-step.tsx
Input numérico para años, input de texto para rol, grupo de checkboxes para certificaciones y un textarea con contador de caracteres para la biografía.
// 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">Experiencia</legend>
<div className="space-y-4">
<AccessibleField
name="yearsOfExperience"
label="Años de experiencia"
type="number"
required
/>
<AccessibleField name="currentRole" label="Rol actual" required />
<div>
<Label>Certificaciones</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} seleccionadas
</p>
</div>
<div>
<Label htmlFor="bio">Biografía</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>
);
}Puntos clave del código:
watch("certifications")se suscribe al valor del array — re-renderiza el componente cuando cambian las certificacionessetValue("certifications", updated, { shouldValidate: true })escribe el array alternado de vuelta y dispara la validaciónCERTIFICATIONSes un arrayconst— mantiene las opciones centralizadas, fácil de añadir/eliminarTextareaconaria-describedbyapunta condicionalmente abio-errorobio-counter— los lectores de pantalla obtienen el contexto correcto- El contador de caracteres cambia a
text-destructiveal superar 1000 — advertencia visual antes de que dispare la validación {certifications.length} seleccionadasmuestra un conteo bajo los checkboxes — retroalimentación rápida sin que el usuario tenga que contar
12. UI del formulario paso 3 -- components/profile-form/job-history-step.tsx
Array de campos dinámico con useFieldArray. Coincide con Gherkin: "admite entradas repetibles", "Añadir otro puesto" y comportamiento de "Eliminar".
// 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">Historial laboral</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">Puesto {index + 1}</h3>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
data-testid={`remove-job-${index}`}
>
Eliminar
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor={`jobs.${index}.company`}>Empresa *</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`}>Rol *</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`}>Fecha de inicio *</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`}>Fecha de fin</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`}>Trabajo aquí actualmente</Label>
</div>
<div>
<Label htmlFor={`jobs.${index}.description`}>Descripción</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"
>
Añadir otro puesto
</Button>
)}
{errors.jobs?.root && (
<p role="alert" className="text-sm text-destructive mt-2">
{errors.jobs.root.message}
</p>
)}
</fieldset>
);
}Puntos clave del código:
useFieldArray({ control, name: "jobs" })gestiona el array dinámico — proporcionafields,appendyremovefield.id(noindex) se usa comokeyde React — estable ante adiciones y eliminaciones, evita valores fantasma en los inputsMAX_JOBS = 10limita el array — el botón "Añadir otro puesto" se oculta al alcanzar el límitefields.length > 1protege el botón Eliminar — no puedes eliminar la última entrada (el esquema requiere al menos una)watch(jobs.$.isCurrent)deshabilita el campo de fecha de fin cuando "Trabajo aquí actualmente" está marcadoerrors.jobs?.[index]?.endDateusa optional chaining para errores anidados del field array — la ruta del error incluye el índice del arrayerrors.jobs?.rootmuestra el error a nivel de array ("Añade al menos un puesto") — separado de los errores por entradaappend({...})añade una entrada en blanco con todos los campos obligatorios inicializados — evita advertencias de inputs no controlados
13. UI del formulario paso 4 -- components/profile-form/skills-step.tsx
Checkboxes de plataformas cloud, multi-selección de especialidades y botones radio para el nivel de competencia por plataforma.
// 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">Habilidades</legend>
<div className="space-y-6">
{/* Plataformas cloud */}
<div>
<Label>Plataformas cloud *</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>
{/* Competencia por plataforma seleccionada */}
{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>
);
})}
{/* Especialidades */}
<div>
<Label>Especialidades *</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>
);
}Puntos clave del código:
togglePlatform/toggleSpecialtygestionan manualmente el estado del array víasetValuecon{ shouldValidate: true }— los checkboxes no usanregister()porque mapean a arrays, no a valores individualeswatch("cloudPlatforms")renderiza dinámicamente los grupos radio de competencia — solo muestra AWS/Azure/GCP cuando la plataforma está seleccionada`${platform}Proficiency` as keyof ArchitectProfilecalcula el nombre del campo dinámicamente — p. ej., seleccionar "aws" renderiza el grupo radioawsProficiencyRadioGroupcononValueChangesincroniza el nivel seleccionado de vuelta al formulario víasetValuePLATFORMSusaas const— TypeScript estrecha elvaluea la unión literal"aws" | "azure" | "gcp"que coincide con el enum zod
14. Subida de archivos -- components/profile-form/file-upload.tsx
Arrastrar y soltar con miniaturas de vista previa, validación de tipo/tamaño en el cliente y un botón de eliminar por archivo. Usa useController para sincronizar archivos con el estado de react-hook-form.
// 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 "Solo se aceptan archivos JPEG, PNG y WebP";
if (file.size > MAX_SIZE) return "El archivo debe pesar menos de 5 MB";
if (multiple && files.length >= maxFiles) return `Máximo ${maxFiles} archivos permitidos`;
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={`Subir ${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 ? "Suelta el archivo aquí" : isMaxReached ? `Máximo ${maxFiles} archivos permitidos` : "Haz clic o arrastra archivos aquí"}
</p>
<p className="text-xs text-muted-foreground mt-1">JPEG, PNG o WebP hasta 5 MB</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 ? "archivo" : "archivos"}</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={`Eliminar ${preview.file.name}`}
data-testid={`remove-file-${index}`}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
);
}Puntos clave del código:
useController({ control, name })sincroniza el estado de archivos con react-hook-form — a diferencia deregister(), funciona con inputs no nativos (zonas de arrastrar/soltar)validateFileejecuta comprobaciones en el cliente (tipo MIME, tamaño, conteo máximo) antes de añadir archivos — retroalimentación rápida sin ida y vuelta al servidorURL.createObjectURL(file)crea miniaturas de vista previa — cada URL se rastrea en el estadopreviewspara la limpiezaURL.revokeObjectURL(p.url)enremoveFileevita fugas de memoria — cada URL creada debe revocarse cuando ya no se necesita- El estado
isDraggingalterna el estilo de borde/fondo al arrastrar — señal visual de que la zona de soltar está activa isMaxReacheddeshabilita la zona de soltar y el input de archivo — evita superar el límite demaxFilesrole="button"+tabIndex={0}+onKeyDownhace la zona de soltar accesible por teclado — Enter/Espacio dispara el selector de archivos- El
<input type="file">oculto conclassName="sr-only"es el input real — la zona visible delega los clics víainputRef - Modo simple vs. múltiple:
field.onChange(validFiles[0])reemplaza en simple,[...files, ...validFiles]añade en múltiple
15. Layout de subida de archivos -- components/profile-form/uploads-step.tsx
Compone tres instancias de FileUpload: foto única, diagramas múltiples, capturas múltiples.
// 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">Subir archivos</legend>
<div className="space-y-6">
<FileUpload name="profilePhoto" label="Foto de perfil" />
<FileUpload name="architectureDiagrams" label="Diagramas de arquitectura" multiple maxFiles={10} />
<FileUpload name="siteScreenshots" label="Capturas del sitio" multiple maxFiles={10} />
</div>
</fieldset>
);
}Puntos clave del código:
- Compone tres instancias de
FileUploadcon configuraciones distintas — simple (foto de perfil) vs. múltiple (diagramas, capturas) maxFiles={10}coincide conMAX_DIAGRAM_COUNTdel esquema zod — los límites de cliente y servidor permanecen sincronizados
16. UX de envío -- components/profile-form/submit-step.tsx
Conecta los valores de react-hook-form con la server action vía FormData. Usa useActionState para el estado pendiente/error. Mapea los errores de campo del servidor de vuelta al paso correcto.
// 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();
// Mapear errores de campo del servidor de vuelta a react-hook-form + navegar al paso
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]);
// Mostrar toast para errores genéricos del servidor
useEffect(() => {
if (state.message && !state.success) {
toast({ variant: "destructive", title: "Error al enviar", description: state.message });
}
}, [state.message, state.success, toast]);
// Construir FormData desde react-hook-form para la 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">Revisa tu perfil</h2>
<div className="space-y-4">
<ReviewCard title="Información personal" step={1} items={[
{ label: "Nombre", value: values.fullName },
{ label: "Correo", value: values.email },
{ label: "Teléfono", value: values.phone },
{ label: "LinkedIn", value: values.linkedinUrl },
]} />
<ReviewCard title="Experiencia" step={2} items={[
{ label: "Años", value: String(values.yearsOfExperience) },
{ label: "Rol", value: values.currentRole },
{ label: "Certificaciones", value: values.certifications.join(", ") },
]} />
<ReviewCard title="Historial laboral" step={3} items={
values.jobs.map((j) => ({ label: j.company, value: j.role }))
} />
<ReviewCard title="Habilidades" step={4} items={[
{ label: "Plataformas", value: values.cloudPlatforms.join(", ") },
{ label: "Especialidades", value: values.specialties.join(", ") },
]} />
<ReviewCard title="Subidas" step={5} items={[
{ label: "Foto de perfil", value: values.profilePhoto ? "Subida" : "Ninguna" },
{ label: "Diagramas", value: `${values.architectureDiagrams?.length ?? 0} archivos` },
{ label: "Capturas", value: `${values.siteScreenshots?.length ?? 0} archivos` },
]} />
</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" />
Enviando...
</>
) : (
"Enviar perfil"
)}
</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}`}
>
Editar
</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>
);
}Puntos clave del código:
useActionState(createProfile, initialState)devuelve[state, formAction, isPending]— conecta la server action con la UI pendiente de ReactFIELD_TO_STEPmapea nombres de campo a números de paso — cuando el servidor devuelve un error de campo, la UI navega al usuario al paso correcto- El primer
useEffectiterastate.fieldErrorsy llama asetError()para cada uno — mapea los errores de validación del servidor de vuelta a react-hook-form para mostrarlos inline goToStep(targetStep)navega automáticamente al paso que contiene el primer error — el usuario no tiene que buscar manualmente qué paso fallóhandleSubmitconstruye manualmenteFormDatadesdegetValues()— conecta el estado de react-hook-form con la entradaFormDataesperada por la server actionformData.append()(noset) se usa para campos array — certificaciones, plataformas, especialidades y subidas múltiples de archivosJSON.stringify(values.jobs)serializa el array de puestos — los objetos anidados complejos no pueden enviarse como entradas individuales deFormDataisPendingdeshabilita el botón de envío y muestra un spinnerLoader2— evita el doble envío- El componente
ReviewCardrenderiza una tarjeta de resumen por paso con un botón "Editar" — permite al usuario volver a cualquier paso antes de enviar
17. Accesibilidad -- components/profile-form/step-announcer.tsx
Región viva invisible que anuncia los cambios de paso a los lectores de pantalla.
// 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 = `Paso ${currentStep} de ${STEPS.length}: ${stepLabel}`;
}
}
}, [currentStep]);
return (
<div
ref={announcerRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
);
}Puntos clave del código:
role="status"+aria-live="polite"crea una región viva — los lectores de pantalla anuncian cambios de contenido sin interrumpir la lectura actualaria-atomic="true"garantiza que se lea el anuncio completo, no solo el texto cambiadoclassName="sr-only"oculta el div visualmente — existe solo para tecnología de asistencia- La ref
previousStepevita re-anunciar cuando el componente se re-renderiza sin cambio de paso announcerRef.current.textContent = ...actualiza el texto de forma imperativa — los cambios de estado de React causarían re-renderizados innecesarios
18. Accesibilidad -- components/profile-form/auto-focus-step.tsx
Mueve el foco al primer input cuando cambia el paso. Garantiza que los usuarios de teclado aterricen en el lugar correcto.
// 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>;
}Puntos clave del código:
querySelectorcon un selector complejo encuentra el primer input enfocable, no oculto y no deshabilitado — funciona para inputs de texto, selects y textareasuseEffectdepende decurrentStep— se dispara cada vez que cambia el paso, moviendo el foco al primer campo del nuevo pasocontainerRefenvuelve el contenido del paso — acota la consulta solo al DOM del paso actual, no a toda la página
19. Entrada de página -- app/profile/create/page.tsx
Página Server Component que envuelve el formulario del lado del cliente en el provider. Aquí es donde App Router se encuentra con el formulario.
// 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">Crea tu perfil</h1>
<ProfileFormProvider>
<ProfileFormWizard />
</ProfileFormProvider>
</main>
);
}Puntos clave del código:
- Esto es un Server Component (sin
"use client") — se renderiza en el servidor y envía HTML al cliente ProfileFormProviderenvuelveProfileFormWizard— el provider crea el contexto del formulario, el asistente lo consume- La página es una capa delgada — toda la lógica vive en los componentes, la página solo los compone
- La ruta
/profile/createse mapea aapp/profile/create/page.tsx— enrutamiento basado en archivos de Next.js App Router
20. Playwright E2E -- e2e/profile-form.spec.ts
Pruebas de extremo a extremo que se mapean directamente a los escenarios Gherkin. Usan los atributos data-testid añadidos a cada componente.
// 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";
// ── Ayudantes ────────────────────────────────────────────
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();
}
// ── Pruebas mapeadas a features Gherkin ───────────────────
test.describe("Feature: Multi-Step Navigation", () => {
test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL);
});
test("el estado inicial muestra el paso 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("completar el paso 1 desbloquea el paso 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("navegar hacia atrás preserva los datos", 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("los campos obligatorios muestran errores cuando están vacíos", async ({ page }) => {
await clickNext(page);
await expect(page.getByText("El nombre completo es obligatorio")).toBeVisible();
await expect(page.getByText("El correo electrónico es obligatorio")).toBeVisible();
await expect(page.getByTestId("step-personal-info")).toBeVisible();
});
test("el formato del correo electrónico se valida", 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("Introduce una dirección de correo válida")).toBeVisible();
});
test("la fecha de fin debe ser posterior a la de inicio", 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("La fecha de fin debe ser posterior a la de inicio")).toBeVisible();
});
});
test.describe("Feature: File Uploads", () => {
test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL);
// Navegar al paso 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("la subida muestra vista previa con nombre y tamaño", 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("rechaza archivos de tamaño excesivo", async ({ page }) => {
// Crear un buffer de 6 MB para la prueba
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("El archivo debe pesar menos de 5 MB");
});
});
test.describe("Feature: Submission UX", () => {
test("el botón de envío muestra el estado de carga", 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);
// Omitir subidas
await clickNext(page);
// En el paso de revisión
await expect(page.getByTestId("step-review")).toBeVisible();
await page.getByTestId("btn-submit-profile").click();
await expect(page.getByText("Enviando...")).toBeVisible();
});
});
test.describe("Feature: Accessibility", () => {
test("los campos obligatorios tienen 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("los campos no válidos tienen aria-invalid tras fallar la validación", async ({ page }) => {
await page.goto(BASE_URL);
await clickNext(page);
await expect(page.getByTestId("field-fullName")).toHaveAttribute("aria-invalid", "true");
});
test("el indicador de paso tiene aria-current en el paso activo", async ({ page }) => {
await page.goto(BASE_URL);
await expect(page.getByTestId("step-1")).toHaveAttribute("aria-current", "step");
});
});Puntos clave del código:
- Las funciones ayudantes (
fillStep1,fillStep2, etc.) reflejan los pasosGivende Gherkin — configuración reutilizable para cada prueba - Los grupos
test.describese mapean a nombresFeaturede Gherkin — "Multi-Step Navigation", "Validation", "File Uploads", etc. test.beforeEachnavega a la página — coincide con el bloqueBackgroundde Gherkin- Los selectores
getByTestId()apuntan a atributosdata-testid— desacoplados de clases CSS o contenido de texto toHaveAttribute("data-status", "completed")afirma el estado del indicador de paso — prueba el mismo atributo que el CSS usa para estilostoHaveValue("Jane Doe")tras navegar hacia atrás verifica la persistencia de datos — coincide con el escenario Gherkin "navegar hacia atrás preserva los datos"setInputFiles()con unBuffer.alloc(6 * 1024 * 1024)crea un archivo sintético de tamaño excesivo — no se necesita un archivo real para la prueba de rechazofillStep1 → clickNext → fillStep2 → clickNext → ...encadena en la prueba de subida la navegación al paso 5 — cada paso debe pasar la validación antes de avanzar- Las aserciones
toBeVisible()en mensajes de error coinciden con GherkinThen debería ver "..."— la prueba verifica la cadena de error exacta visible para el usuario - Las pruebas de accesibilidad verifican
aria-required,aria-invalidyaria-current— garantiza que los contratos ARIA documentados en la especificación Gherkin estén implementados
Diagrama de flujo de responsabilidades
Gherkin Feature Files
│
▼
┌──────────────────────────────┐
│ lib/schemas/ │ ← Reglas de validación
│ architect-profile.ts │ (zod compartido, única fuente de verdad)
└──────────┬───────────────────┘
│ importado por
┌─────┴─────┐
▼ ▼
┌──────────┐ ┌─────────────────────────┐
│ Cliente │ │ Servidor │ ← Validación dual
│ zodRes. │ │ safeParse en action │
└────┬─────┘ └────────┬────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ react-hook- │ │ lib/actions/ │ ← Lógica de envío
│ form + UI │ │ create-profile.ts │
│ components │ │ (FormData → BD) │
└────┬─────────┘ └───────┬──────────────┘
│ │
▼ │
┌──────────────┐ │
│ zustand │ ◄───────┘ ← Lógica de pasos + enrutamiento de errores
│ step store │ (FIELD_TO_STEP navega al paso con error)
└────┬─────────┘
│
▼
┌──────────────┐
│ Playwright │ ← Las pruebas E2E verifican escenarios Gherkin
│ e2e/ │ vía selectores data-testid
└──────────────┘
Receta
Referencia rápida: qué va dónde.
| Necesitas... | Ponlo en... |
|---|---|
| Añadir una regla de validación | lib/schemas/architect-profile.ts (zod) |
| Añadir un nuevo paso al formulario | Nuevo componente de paso + añadir a STEP_COMPONENTS en el wizard + actualizar STEPS en el store |
| Cambiar las reglas de navegación entre pasos | stores/profile-form-store.ts (canNavigateTo) |
| Manejar un nuevo error del servidor | lib/actions/create-profile.ts + FIELD_TO_STEP en submit-step |
| Añadir un campo de subida de archivos | uploadsSchema en el esquema + nuevo <FileUpload> en uploads-step |
| Escribir una nueva prueba E2E | e2e/profile-form.spec.ts usando selectores data-testid |
| Cambiar los criterios de aceptación | features/profile-form/*.feature primero, luego actualiza el código para que coincida |
Errores comunes
-
data-testidfaltante -- Las pruebas de Playwright dependen de atributosdata-testid. Cada elemento interactivo necesita uno. Añádelos cuando creas un componente, no después. -
El archivo de esquema importa módulos del servidor --
lib/schemas/architect-profile.tsse comparte entre cliente y servidor. No debe importar nada de archivos"use server",next/headersni clientes de base de datos. -
Deriva de índice en
useFieldArray-- cuando eliminas una entrada de puesto, todos los índices posteriores se desplazan. Usafield.id(de useFieldArray) como key de React, no el índice del array. -
Vistas previas de archivos sin limpiar -- cada
URL.createObjectURLdebe tener unrevokeObjectURLcuando se elimina el archivo. El componenteFileUploadlo maneja, pero si construyes UI de subida personalizada, rastrea tus URLs. -
redirect()en server actions --redirect()de Next.js lanza un error interno. Tu try/catch en la server action debe relanzarlo, o la redirección falla en silencio. -
Subida de archivos en Playwright -- usa
page.locator('input[type=file]').setInputFiles()con una ruta de archivo real o unBuffer. La zona visual de soltar no es un input de archivo real, así que apunta al<input>oculto dentro.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
Un solo page.tsx con todos los pasos inline | Prototipo o menos de 10 campos | Formulario de producción con 20+ campos |
| Redux en lugar de zustand | Ya usas Redux en la app | Empiezas de cero (zustand es más ligero) |
| Conform en lugar de react-hook-form | La mejora progresiva es crítica | Interacciones complejas en el cliente (arrastrar/soltar, vistas previas) |
| Pruebas de componentes con Vitest en lugar de Playwright | Pruebas unitarias rápidas del formulario | Necesitas probar comportamiento real del navegador (subidas, navegación) |
Relacionado
- Lista de decisión de formularios Gherkin -- la lista de 10 preguntas que define QUÉ construir
- Gherkin a código -- cómo cada escenario Gherkin se mapea a patrones de código
- Pruebas de formularios -- pruebas unitarias de formularios con Testing Library
- Patrones de Playwright -- buenas prácticas de Playwright
- Formularios y validación -- profundización en react-hook-form y zod