De Gherkin a código
Convierte requisitos BDD en Gherkin en una implementación real con React 19 + Next.js 15. Cada sección muestra primero el escenario Gherkin y después el código exacto que lo cumple.
El proyecto de ejemplo es un formulario de perfil de arquitecto cloud — un formulario de varios pasos con 6 etapas, subida de archivos, validación por esquema, server actions y una UI accesible.
De Gherkin a esquema Zod
Los escenarios de validación en Gherkin se traducen directamente en esquemas zod. Cada Then they should see "..." se mapea a un mensaje de error de zod.
Gherkin
Feature: Profile Validation Schema
Scenario: Full name is required and has minimum length
Given the architect submits step 1 with an empty "Full Name" field
Then they should see "Full name is required"
Scenario: Full name must be at least 2 characters
Given the architect types "A" in the "Full Name" field
When they click "Next"
Then they should see "Name must be at least 2 characters"
Scenario: Email must be valid format
Given the architect types "not-an-email" in the "Email" field
When they click "Next"
Then they should see "Please enter a valid email address"
Scenario: LinkedIn URL must point to linkedin.com
Given the architect types "https://twitter.com/someone" in the "LinkedIn URL"
When 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 types "-3" in the "Years of Experience" field
When 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 checks no cloud platforms on step 4
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 sets start date "2025-06-01" and end date "2024-01-01"
When they click "Next"
Then they should see "End date must be after start date"
Scenario: Bio cannot exceed 1000 characters
Given the architect types 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 selects 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 selects a .exe file for "Profile Photo"
Then they should see "Only JPEG, PNG, and WebP files are accepted"Código
// lib/schemas/architect-profile.ts
import { z } from "zod";
// ── 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 email es obligatorio")
.email("Introduce una dirección de email 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 puesto actual es obligatorio"),
certifications: z
.array(z.string())
.min(0),
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 puesto 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 ────────────────────────────────────
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const MAX_DIAGRAM_COUNT = 10;
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"
);
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(),
});
// ── Esquema combinado ────────────────────────────────────
export const architectProfileSchema = personalInfoSchema
.merge(experienceSchema)
.merge(jobHistorySchema)
.merge(skillsSchema)
.merge(uploadsSchema);
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>;Cómo se corresponde: Cada Gherkin Then they should see "..." se convierte en la cadena exacta de un mensaje de zod en .min(), .max(), .refine() o .email(). El esquema ES la especificación.
De Gherkin a configuración de react-hook-form
Los escenarios de interacción con campos en Gherkin definen cómo se conectan register, useFieldArray y zodResolver.
Gherkin
Feature: Form Field Registration
Scenario: Personal info fields are wired to form state
Given the architect is on step 1 "Personal Info"
When they type "Jane Doe" in "Full Name"
And they type "jane@example.com" in "Email"
And they type "+1-555-0100" in "Phone"
Then the form state should contain those values
Scenario: Certification multi-select tracks checked items
Given the architect is on step 2 "Experience"
When they check "AWS Solutions Architect Professional"
And they check "Terraform Associate"
Then the certifications array should contain both values
Scenario: Job history supports adding and removing entries
Given the architect is on step 3 "Job History"
And they have 2 job entries
When they click "Remove" on the first entry
Then only 1 job entry should remain
When they click "Add Another Position"
Then 2 job entries should appear again
Scenario: Validation runs on blur for immediate feedback
Given the architect is on step 1 "Personal Info"
When they type "bad" in the "Email" field
And they move focus to the next field
Then they should see "Please enter a valid email address"Código
// components/profile-form/personal-info-step.tsx
"use client";
import { useFormContext } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
export function PersonalInfoStep() {
const {
register,
formState: { errors },
} = useFormContext<ArchitectProfile>();
return (
<fieldset>
<legend className="text-lg font-semibold">Información personal</legend>
<div className="space-y-4">
<div>
<Label htmlFor="fullName">
Nombre completo <span aria-hidden="true">*</span>
</Label>
<Input
id="fullName"
aria-required="true"
aria-invalid={!!errors.fullName}
aria-describedby={errors.fullName ? "fullName-error" : undefined}
{...register("fullName")}
/>
{errors.fullName && (
<p id="fullName-error" role="alert" className="text-sm text-destructive mt-1">
{errors.fullName.message}
</p>
)}
</div>
<div>
<Label htmlFor="email">
Email <span aria-hidden="true">*</span>
</Label>
<Input
id="email"
type="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive mt-1">
{errors.email.message}
</p>
)}
</div>
<div>
<Label htmlFor="phone">Teléfono</Label>
<Input id="phone" type="tel" {...register("phone")} />
</div>
<div>
<Label htmlFor="linkedinUrl">URL de LinkedIn</Label>
<Input
id="linkedinUrl"
type="url"
placeholder="https://linkedin.com/in/your-profile"
aria-invalid={!!errors.linkedinUrl}
aria-describedby={errors.linkedinUrl ? "linkedin-error" : undefined}
{...register("linkedinUrl")}
/>
{errors.linkedinUrl && (
<p id="linkedin-error" role="alert" className="text-sm text-destructive mt-1">
{errors.linkedinUrl.message}
</p>
)}
</div>
</div>
</fieldset>
);
}// 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>
<legend className="text-lg font-semibold">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">
<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)}
>
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"
{...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`}>Puesto *</Label>
<Input
id={`jobs.${index}.role`}
aria-required="true"
{...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"
{...register(`jobs.${index}.startDate`)}
/>
</div>
<div>
<Label htmlFor={`jobs.${index}.endDate`}>Fecha de fin</Label>
<Input
id={`jobs.${index}.endDate`}
type="date"
disabled={isCurrent}
{...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`)}
/>
<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}
{...register(`jobs.${index}.description`)}
/>
</div>
</div>
);
})}
{fields.length < MAX_JOBS && (
<Button
type="button"
variant="outline"
onClick={() =>
append({
company: "",
role: "",
startDate: "",
endDate: "",
isCurrent: false,
description: "",
})
}
>
Añadir otro puesto
</Button>
)}
{errors.jobs?.root && (
<p role="alert" className="text-sm text-destructive mt-2">
{errors.jobs.root.message}
</p>
)}
</fieldset>
);
}// 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: "", isCurrent: false }],
cloudPlatforms: [],
specialties: [],
};
interface ProfileFormProviderProps {
children: React.ReactNode;
}
export function ProfileFormProvider({ children }: ProfileFormProviderProps) {
const methods = useForm<ArchitectProfile>({
resolver: zodResolver(architectProfileSchema),
defaultValues,
mode: "onBlur", // ← Gherkin: "mover el foco al siguiente campo" activa la validación
});
return <FormProvider {...methods}>{children}</FormProvider>;
}Cómo se corresponde:
When they type "..." in "..."→register("fieldName")en el inputThen the form state should contain those values→ react-hook-form rastrea los valores automáticamente mediante inputs no controladosWhen they click "Add Another Position"→useFieldArray.append()When they click "Remove"→useFieldArray.remove(index)And they move focus to the next field→mode: "onBlur"enuseForm
De Gherkin a store multi-paso con Zustand
Los escenarios de navegación en Gherkin definen la máquina de estados de los pasos: cuál está activo, cuáles están completados y cómo se restringe la navegación.
Gherkin
Feature: Multi-Step Form State
Scenario: Initial state shows step 1
Given the architect opens the profile form
Then the current step should be 1
And steps 2 through 6 should be locked
And the "Back" button should be hidden
Scenario: Completing step 1 unlocks step 2
Given the architect fills all required fields on step 1
When they click "Next"
Then the current step should be 2
And step 1 should show a checkmark
And the "Back" button should be visible
Scenario: Navigating back preserves completed status
Given the architect has completed steps 1 through 3
And they are on step 4
When they click "Back"
Then the current step should be 3
And steps 1 through 3 should still show checkmarks
Scenario: Clicking a completed step jumps to it
Given the architect has completed steps 1 through 4
And they are on step 5
When they click step 2 in the progress indicator
Then the current step should be 2
Scenario: Cannot jump ahead past incomplete steps
Given the architect is on step 2
And step 3 has not been completed
When they click step 4 in the progress indicator
Then the current step should remain 2
Scenario: Review step shows all entered data
Given the architect has completed steps 1 through 5
When they arrive at step 6 "Review"
Then they should see a summary of all entered data
And each section should have an "Edit" linkCódigo
// 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>;
// Acciones
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();
// Siempre se puede ir al paso actual o a los completados
if (step === currentStep) return true;
if (completedSteps.has(step)) return true;
// Se puede ir al siguiente paso si todos los anteriores están completados
if (step === currentStep + 1) return true;
return false;
},
reset: () => set({ currentStep: 1, completedSteps: new Set() }),
}));// components/profile-form/step-indicator.tsx
"use client";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import {
STEPS,
useProfileFormStore,
type StepStatus,
} from "@/stores/profile-form-store";
export function StepIndicator() {
const { currentStep, getStepStatus, canNavigateTo, goToStep } =
useProfileFormStore();
return (
<nav aria-label="Progreso del formulario">
<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)" : ""
}`}
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>
);
}// 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";
// Mapea el número de paso a los campos que deben validarse antes de avanzar
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">
{!isFirstStep ? (
<Button type="button" variant="outline" onClick={goToPrevious}>
Atrás
</Button>
) : (
<div /> // Espaciador
)}
{!isLastStep ? (
<Button type="button" onClick={handleNext}>
Siguiente
</Button>
) : (
<Button type="submit">Enviar perfil</Button>
)}
</div>
);
}Cómo se corresponde:
Then the current step should be 1→ statecurrentStepen zustandAnd steps 2 through 6 should be locked→canNavigateTo()devuelve falseThen step 1 should show a checkmark→getStepStatus(1) === "completed"renderiza<Check />When they click step 2 in the progress indicator→goToStep(2)When they click "Next"→ valida los campos del paso actual contrigger(), luegogoToNext()
De Gherkin a Server Actions
Los escenarios de envío en Gherkin definen el contrato de la server action: qué recibe, qué devuelve y cómo gestiona los errores.
Gherkin
Feature: Profile Submission Server Action
Scenario: Successful profile creation
Given the architect submits a valid complete profile
Then the server should receive all form fields and files
And a new profile record should be created
And the response should contain the profile ID
And the architect should be redirected to "/profile/me"
Scenario: Server rejects duplicate email
Given the architect submits a profile with email "taken@example.com"
And that email already exists in the database
Then the server should return field error "This email is already registered"
And the error should be associated with the "email" field
Scenario: Server validates file types on backend
Given an attacker bypasses client-side validation
And submits a PHP file disguised as a JPEG
Then the server should reject the file
And return "Invalid file type"
Scenario: Server handles unexpected errors gracefully
Given the database is temporarily unavailable
When the architect submits a profile
Then the server should return "Something went wrong. Please try again."
And no partial data should be persistedCódigo
// lib/actions/create-profile.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
import { architectProfileSchema } from "@/lib/schemas/architect-profile";
// Esquema solo del servidor — más estricto que el del cliente
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 con zod ─────────────
const textResult = architectProfileSchema
.omit({ profilePhoto: true, architectureDiagrams: true, siteScreenshots: true })
.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,
},
};
}
}
}
// ── 4. Comprobar email duplicado ─────────────────
const existingProfile = await findProfileByEmail(textResult.data.email);
if (existingProfile) {
return {
success: false,
message: "",
fieldErrors: { email: "Este email 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"))
);
// ── 6. Crear registro de perfil ─────────────────────
const profile = await createProfileRecord({
...textResult.data,
photoUrl,
diagramUrls,
});
redirect(`/profile/${profile.id}`);
} catch (error) {
// redirect() lanza un error NEXT_REDIRECT — 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: {},
};
}
}
// Funciones de datos de ejemplo — sustituye por tu capa real de BD/almacenamiento
async function findProfileByEmail(email: string) {
// db.profile.findUnique({ where: { email } })
return null;
}
async function uploadToStorage(file: File, folder: string): Promise<string> {
// Subir a S3/Cloudflare R2/etc. y devolver la URL
return `https://storage.example.com/${folder}/${file.name}`;
}
async function createProfileRecord(data: Record<string, unknown>) {
// db.profile.create({ data })
return { id: "new-profile-id" };
}Cómo se corresponde:
Then the server should receive all form fields and files→ parseo deFormDataen la actionThen the server should return field error "..."→fieldErrorsen el tipo de retornoAnd the error should be associated with the "email" field→{ email: "..." }enfieldErrorsThen the server should reject the file→ validación dual conserverFileSchemaThen the server should return "Something went wrong..."→ el bloque catch devuelve un mensaje genérico
De Gherkin a useActionState: estados pendiente y UX de error
Los escenarios de Gherkin para estados de carga, visualización de errores y recuperación definen cómo useActionState se conecta a la UI.
Gherkin
Feature: Submission UX with useActionState
Scenario: Submit button shows loading state
Given the architect clicks "Submit Profile"
Then the button text should change to "Submitting..."
And the button should be disabled
And a spinner should appear
Scenario: Field errors navigate to the correct step
Given the server returns an error on the "email" field
Then the form should navigate to step 1
And the "Email" field should be highlighted with an error
And the screen reader should announce the error
Scenario: Generic server error shows toast notification
Given the server returns "Something went wrong. Please try again."
Then a toast notification should appear with the error message
And the submit button should be re-enabled
And all entered data should be preserved
Scenario: Successful submission shows success state
Given the server creates the profile successfully
Then the architect should see "Profile created successfully!"
And they should be redirected to their profile pageCódigo
// components/profile-form/submit-step.tsx
"use client";
import { useActionState, useEffect, useRef } 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";
// Mapea nombres de campo a números de paso para la navegación por errores
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();
const errorAnnouncerRef = useRef<HTMLDivElement>(null);
// Gestionar errores de campo devueltos por el servidor
useEffect(() => {
if (!state.fieldErrors || Object.keys(state.fieldErrors).length === 0) return;
// Establecer errores en los campos de react-hook-form
const firstErrorField = Object.keys(state.fieldErrors)[0];
for (const [field, message] of Object.entries(state.fieldErrors)) {
setError(field as keyof ArchitectProfile, { message });
}
// Navegar al paso que contiene el primer error
const targetStep = FIELD_TO_STEP[firstErrorField];
if (targetStep) {
goToStep(targetStep);
}
}, [state.fieldErrors, setError, goToStep]);
// Gestionar 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 los valores de react-hook-form para la server action
const handleSubmit = () => {
const values = getValues();
const formData = new FormData();
// Campos de texto
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));
// Campos de varios valores
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);
}
// Archivos
if (values.profilePhoto) {
formData.set("profilePhoto", values.profilePhoto);
}
if (values.architectureDiagrams) {
for (const diagram of values.architectureDiagrams) {
formData.append("architectureDiagrams", diagram);
}
}
if (values.siteScreenshots) {
for (const screenshot of values.siteScreenshots) {
formData.append("siteScreenshots", screenshot);
}
}
formAction(formData);
};
const values = getValues();
return (
<div>
<h2 className="text-lg font-semibold mb-4">Revisa tu perfil</h2>
{/* ── Secciones de resumen con enlaces de edición ── */}
<div className="space-y-4">
<ReviewSection
title="Información personal"
step={1}
items={[
{ label: "Nombre", value: values.fullName },
{ label: "Email", value: values.email },
{ label: "Teléfono", value: values.phone },
{ label: "LinkedIn", value: values.linkedinUrl },
]}
/>
<ReviewSection
title="Experiencia"
step={2}
items={[
{ label: "Años", value: String(values.yearsOfExperience) },
{ label: "Puesto", value: values.currentRole },
{ label: "Certificaciones", value: values.certifications.join(", ") },
]}
/>
<ReviewSection
title="Historial laboral"
step={3}
items={values.jobs.map((job) => ({
label: job.company,
value: job.role,
}))}
/>
<ReviewSection
title="Habilidades"
step={4}
items={[
{ label: "Plataformas", value: values.cloudPlatforms.join(", ") },
{ label: "Especialidades", value: values.specialties.join(", ") },
]}
/>
<ReviewSection
title="Subidas"
step={5}
items={[
{
label: "Foto de perfil",
value: values.profilePhoto ? "Subido" : "Ninguno",
},
{
label: "Diagramas",
value: `${values.architectureDiagrams?.length ?? 0} archivos`,
},
]}
/>
</div>
{/* Anunciador de errores para lectores de pantalla */}
<div ref={errorAnnouncerRef} role="alert" aria-live="assertive" className="sr-only" />
{/* Botón de envío con estado pendiente */}
<div className="pt-6">
<Button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="w-full"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
Enviando...
</>
) : (
"Enviar perfil"
)}
</Button>
</div>
</div>
);
}
// ── Sección de revisión con enlace de edición ──────────────────────
function ReviewSection({
title,
step,
items,
}: {
title: string;
step: number;
items: { label: string; value?: string }[];
}) {
const { goToStep } = useProfileFormStore();
return (
<div className="border rounded-lg p-4">
<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)}
>
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 || "—"}</dd>
</div>
))}
</dl>
</div>
);
}Cómo se corresponde:
Then the button text should change to "Submitting..."→isPendingdeuseActionStateAnd a spinner should appear→<Loader2 className="animate-spin" />Then the form should navigate to step 1→ búsqueda enFIELD_TO_STEP+goToStep()And the screen reader should announce the error→role="alert"yaria-live="assertive"Then a toast notification should appear→useToast()en el efecto de error
De Gherkin a gestión de subida de archivos
Los escenarios de subida en Gherkin definen la vista previa, la validación, el arrastrar y soltar, y el comportamiento de eliminación.
Gherkin
Feature: File Upload Component
Scenario: Architect uploads a profile photo and sees preview
Given the architect is on step 5 "Uploads"
When they select a JPEG file "headshot.jpg" (2MB) for "Profile Photo"
Then a thumbnail preview of "headshot.jpg" should appear
And the file name "headshot.jpg" should be displayed
And the file size "2 MB" should be displayed
Scenario: Drag and drop into upload zone
Given the architect drags a PNG file over the "Architecture Diagrams" zone
Then the drop zone border should turn blue
And the text should change to "Drop file here"
When they drop the file
Then a thumbnail preview should appear
And the drop zone should return to its default state
Scenario: Removing an uploaded file
Given the architect has uploaded 3 architecture diagrams
When they click the remove button on "diagram-2.png"
Then "diagram-2.png" should be removed from the preview list
And 2 thumbnails should remain
And the "Architecture Diagrams" file count should show "2 files"
Scenario: Client-side validation rejects oversized file
Given the architect selects a 15MB file for "Profile Photo"
Then the file should not be added to the preview
And they should see "File must be under 5MB"
Scenario: Client-side validation rejects wrong file type
Given the architect selects a .pdf file for "Profile Photo"
Then the file should not be added to the preview
And they should see "Only JPEG, PNG, and WebP files are accepted"
Scenario: Maximum file count enforced
Given the architect has uploaded 10 architecture diagrams
When they try to add another file
Then the file input should be disabled
And they should see "Maximum 10 files allowed"Código
// 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, setError, 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);
const fileArray = Array.from(newFiles);
const validFiles: File[] = [];
for (const file of fileArray) {
const error = validateFile(file);
if (error) {
setClientError(error);
return; // Detener en el primer error
}
validFiles.push(file);
}
if (validFiles.length === 0) return;
// Crear URLs de vista previa
const newPreviews = validFiles.map((file) => ({
file,
url: URL.createObjectURL(file),
}));
if (multiple) {
const updated = [...files, ...validFiles];
field.onChange(updated);
setPreviews((prev) => [...prev, ...newPreviews]);
} else {
// Revocar la vista previa anterior
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) {
const updated = files.filter((_, i) => i !== index);
field.onChange(updated);
setPreviews((prev) => prev.filter((_, i) => i !== index));
} else {
field.onChange(undefined);
setPreviews([]);
}
// Restablecer el input para poder volver a seleccionar el mismo archivo
if (inputRef.current) inputRef.current.value = "";
},
[field, files, multiple, previews]
);
// ── Manejadores de arrastrar y soltar ──
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">
<label className="text-sm font-medium">{label}</label>
{/* Zona de soltar */}
<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",
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 de 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"
/>
{/* Error de validación del cliente */}
{clientError && (
<p role="alert" className="text-sm text-destructive">
{clientError}
</p>
)}
{/* Contador de archivos */}
{multiple && files.length > 0 && (
<p className="text-sm text-muted-foreground">
{files.length} {files.length === 1 ? "archivo" : "archivos"}
</p>
)}
{/* Vistas previas */}
{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}`}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
);
}// components/profile-form/uploads-step.tsx
"use client";
import { FileUpload } from "./file-upload";
export function UploadsStep() {
return (
<fieldset>
<legend className="text-lg font-semibold">Subir archivos</legend>
<div className="space-y-6 mt-4">
<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>
);
}Cómo se corresponde:
Then a thumbnail preview should appear→URL.createObjectURL()enaddFiles()And the file name "headshot.jpg" should be displayed→preview.file.nameen la cuadrícula de vistas previasThen the drop zone border should turn blue→isDragging && "border-primary bg-primary/5"And the text should change to "Drop file here"→isDragging ? "Suelta el archivo aquí" : ...When they click the remove button→removeFile(index)revoca la URL y actualiza el campoThen the file input should be disabled→isMaxReacheddeshabilita el input y la zona de soltar
De Gherkin a accesibilidad con shadcn
Los escenarios de accesibilidad en Gherkin definen los atributos ARIA, el comportamiento del teclado y los anuncios para lectores de pantalla que deben cumplir los componentes de shadcn/ui.
Gherkin
Feature: Accessible Form with shadcn/ui
Scenario: Every required field has accessible markup
Given the architect opens the form
Then every required input should have aria-required="true"
And every input should have an associated label via htmlFor/id
And required fields should show a visual asterisk
Scenario: Validation errors are announced to screen readers
Given the architect submits step 1 with empty required fields
Then each error message should have role="alert"
And the first error message should receive focus
And invalid inputs should have aria-invalid="true"
Scenario: Error messages are linked to inputs
Given the "Email" field has a validation error
Then the input should have aria-describedby pointing to the error message
And a screen reader should read "Email, invalid, Please enter a valid email address"
Scenario: Step changes are announced
Given the architect moves from step 1 to step 2
Then the screen reader should announce "Step 2 of 6: Experience"
And focus should move to the first field of step 2
Scenario: Keyboard navigation works through the form
Given the architect is on step 1
When they press Tab repeatedly
Then focus should move through: Full Name, Email, Phone, LinkedIn URL, Next button
And pressing Enter on the Next button should advance the form
Scenario: File upload is keyboard accessible
Given the architect tabs to the upload drop zone
Then the drop zone should show a focus ring
When they press Enter
Then the file picker dialog should openCódigo
// 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`;
const descriptionId = error ? errorId : undefined;
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={descriptionId}
{...register(name)}
/>
{error && (
<p
id={errorId}
role="alert"
className="text-sm text-destructive"
>
{error.message as string}
</p>
)}
</div>
);
}// 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"
/>
);
}// 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(() => {
// Enfocar el primer elemento enfocable del nuevo paso
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>;
}Cómo se corresponde:
Then every required input should have aria-required="true"→ proparia-required={required}And invalid inputs should have aria-invalid="true"→aria-invalid={!!error}Then the input should have aria-describedby pointing to the error message→aria-describedby={errorId}Then the screen reader should announce "Step 2 of 6: Experience"→StepAnnouncerconaria-live="polite"And focus should move to the first field of step 2→AutoFocusStepconsulta el primer inputThen the drop zone should show a focus ring→tabIndex={0}+ Tailwindfocus-visible:ring-2en la zona de soltar
De Gherkin a validación dual (cliente + servidor)
Los escenarios de seguridad en Gherkin definen por qué la validación debe ejecutarse tanto en el cliente como en el servidor.
Gherkin
Feature: Dual Validation
Scenario: Client-side validation gives instant feedback
Given the architect types "bad-email" in the "Email" field
And they blur the field
Then they should see "Please enter a valid email address" within 100ms
And no network request should be made
Scenario: Server-side validation catches bypassed client checks
Given an attacker sends a direct POST request
And the payload contains email "not-an-email"
Then the server should reject the request
And return error "Please enter a valid email address"
Scenario: File type is validated on both client and server
Given the architect uploads a file named "exploit.php.jpg"
And the file's actual MIME type is "application/x-php"
Then the client should reject based on MIME type check
And if somehow it reaches the server, the server should also reject it
Scenario: Both validations use the same zod schema
Given the schema defines email as z.string().email()
Then the client-side resolver should use that schema
And the server action should use that same schema
And error messages should be identical on both sidesCódigo
// lib/schemas/architect-profile.ts (compartido — usado por cliente Y servidor)
// Este es el MISMO archivo mostrado en "De Gherkin a esquema Zod" más arriba.
// El punto clave: UN esquema, DOS consumidores.
import { z } from "zod";
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 email es obligatorio").email("Introduce una dirección de email válida"),
// ... resto del esquema
});// El cliente lo usa mediante zodResolver:
// components/profile-form/profile-form-provider.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { architectProfileSchema } from "@/lib/schemas/architect-profile";
const methods = useForm<ArchitectProfile>({
resolver: zodResolver(architectProfileSchema), // ← mismo esquema
mode: "onBlur",
});// El servidor lo usa mediante safeParse:
// lib/actions/create-profile.ts
"use server";
import { architectProfileSchema } from "@/lib/schemas/architect-profile"; // ← mismo esquema
export async function createProfile(prevState: ProfileActionState, formData: FormData) {
const result = architectProfileSchema.safeParse(rawData); // ← mismo esquema
if (!result.success) {
// Devolver errores de campo — los mensajes son idénticos al lado del cliente
}
}Cómo se corresponde:
Then they should see "..." within 100ms→zodResolverdel cliente se ejecuta en el navegador, sin redThen the server should reject the request→safeParseen la server actionThen error messages should be identical on both sides→ el mismo archivo de esquema importado por ambos- La arquitectura:
lib/schemas/es una capa compartida. Los componentes del cliente lo importan parazodResolver. Las server actions lo importan parasafeParse. Una única fuente de verdad.
De Gherkin al ensamblaje completo del formulario
Los escenarios de integración en Gherkin definen cómo se componen todas las piezas en el formulario completo.
Gherkin
Feature: Complete Profile Form Assembly
Scenario: Form renders all 6 steps with correct flow
Given the architect navigates to "/profile/create"
Then the step indicator should show 6 steps
And step 1 "Personal Info" should render with name, email, phone, LinkedIn fields
When they complete step 1 and click "Next"
Then step 2 "Experience" should render with years, role, certifications, bio fields
When they complete step 2 and click "Next"
Then step 3 "Job History" should render with repeatable job entry groups
When they complete step 3 and click "Next"
Then step 4 "Skills" should render with platform checkboxes and proficiency radios
When they complete step 4 and click "Next"
Then step 5 "Uploads" should render with photo, diagrams, and screenshots upload zones
When they complete step 5 and click "Next"
Then step 6 "Review" should show a summary of all data with edit links
And the submit button should be enabledCódigo
// 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>
);
}// 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">
<StepIndicator />
<StepAnnouncer />
<AutoFocusStep>
<StepComponent />
</AutoFocusStep>
<StepNavigation />
</div>
);
}Estructura completa de archivos
app/
profile/
create/
page.tsx ← Componente de página
components/
profile-form/
profile-form-provider.tsx ← useForm + FormProvider + zodResolver
profile-form-wizard.tsx ← Enrutador de pasos
step-indicator.tsx ← Barra de progreso con marcas de verificación
step-navigation.tsx ← Botones Atrás/Siguiente/Enviar
step-announcer.tsx ← Anuncios de paso para lectores de pantalla
auto-focus-step.tsx ← Autoenfoque del primer campo al cambiar de paso
accessible-field.tsx ← Input accesible reutilizable
personal-info-step.tsx ← Paso 1
experience-step.tsx ← Paso 2
job-history-step.tsx ← Paso 3 (useFieldArray)
skills-step.tsx ← Paso 4
uploads-step.tsx ← Paso 5
file-upload.tsx ← Subida de archivos con arrastrar/soltar y vista previa
submit-step.tsx ← Paso 6: Revisar + envío con useActionState
lib/
schemas/
architect-profile.ts ← Esquemas zod compartidos (cliente + servidor)
actions/
create-profile.ts ← Server Action
stores/
profile-form-store.ts ← State de pasos con Zustand
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
El patrón de traducción de Gherkin a código:
| Palabra clave Gherkin | Destino en código |
|---|---|
Given ... field is empty | zod .min(1, "...") |
Then they should see "error" | cadena de mensaje de error de zod |
When they type "..." | register("fieldName") en el input |
When they click "Next" | trigger(stepFields) luego goToNext() |
When they click "Add Another" | useFieldArray.append() |
Then a spinner should appear | isPending de useActionState |
Then the server should reject | safeParse() en la server action |
Then role="alert" | <p role="alert"> en los mensajes de error |
Then screen reader announces | región con aria-live="polite" |
Then thumbnail preview | URL.createObjectURL(file) |
When they drag a file | manejadores onDragOver/onDrop |
Cuándo usarlo: Después de completar el Checklist de decisiones de Gherkin. Usa el checklist para definir QUÉ hace el formulario y esta guía para implementar CÓMO.
Errores comunes
-
Compartir esquemas entre cliente y servidor — el archivo de esquema NO debe importar módulos exclusivos del servidor. Mantenlo en
lib/schemas/con solo importaciones de zod. -
useActionState requiere una server action de dos argumentos — el primer argumento es el state anterior, no los datos del formulario.
(prevState, formData) => ...y no(formData) => .... -
FormData y react-hook-form — react-hook-form gestiona el state internamente. Para enviar mediante server action, debes construir
FormDatamanualmente desdegetValues(). La prop nativaactiondel formulario omite react-hook-form. -
El store de Zustand se restablece al navegar entre páginas — si el usuario sale y vuelve, el store de pasos se reinicia. Considera el middleware
persistsi necesitas persistencia entre navegaciones. -
Las vistas previas de archivos filtran memoria — cada
URL.createObjectURL()debe tener suURL.revokeObjectURL()correspondiente al eliminar o desmontar. El componenteFileUploadlo gestiona enremoveFile(). -
Server actions y redirect() —
redirect()lanza internamente un error especial. Tu bloque catch debe relanzarlo o la redirección se perderá.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
| Formik en lugar de react-hook-form | El proyecto legacy ya usa Formik | Empiezas de cero (RHF tiene mejor rendimiento) |
| Redux para el state de pasos | La app ya usa Redux intensivamente | Necesidades de state más simples (zustand es más ligero) |
| tRPC en lugar de server actions | Necesitas una capa API con tipos más allá de formularios | Envío simple de formulario a tu propio backend |
| Conform para formularios server-first | La mejora progresiva es la máxima prioridad | Necesitas interacciones complejas del lado del cliente |
Relacionado
- Checklist de decisiones de formularios con Gherkin — el checklist de 10 preguntas que precede a esta implementación
- Pruebas de formularios — pruebas unitarias de componentes de formulario
- Sección de formularios y validación — patrones de react-hook-form y zod
- Patrones de Playwright — pruebas E2E del formulario