Sistema de Validación de Formularios¶
Introducción¶
Este documento describe el sistema de validación de formularios implementado en el frontend, que combina validación del lado del cliente con Zod y validación del servidor mediante Laravel FormRequests.
Arquitectura¶
Principios de Diseño¶
- El servidor es la fuente de verdad: La validación del cliente es para mejorar la UX, pero la validación del servidor es autoritativa
- Estado único:
useForm
de Inertia.js es la única fuente de estado del formulario - Accesibilidad: Todos los componentes siguen las mejores prácticas ARIA
- Retroalimentación inmediata: Validación en
blur
con debounce y ensubmit
- Sin duplicación: Reutilización de componentes y helpers existentes
Componentes del Sistema¶
1. Librería de Validación Base¶
/resources/js/lib/validation/validation.config.ts
¶
Contiene constantes de configuración y mensajes de validación en español.
import { MAX_LENGTHS, VALIDATION_MESSAGES } from '@/lib/validation/validation.config';
// Usar las constantes definidas
const maxNameLength = MAX_LENGTHS.DEFAULT_STRING;
const requiredMessage = VALIDATION_MESSAGES.REQUIRED;
/resources/js/lib/validation/zod-kit.ts
¶
Helpers de Zod preconfigurados con mensajes en español para tipos comunes.
import { requiredString, requiredBoolean, numericIdArray } from '@/lib/validation/zod-kit';
// Crear un esquema con helpers
const schema = z.object({
name: requiredString().max(100),
is_active: requiredBoolean(),
permissions_ids: numericIdArray(),
});
2. Hooks de Validación¶
useClientValidation
¶
Hook principal para validación del lado del cliente con Zod.
import { useClientValidation } from '@/hooks/useClientValidation';
// En tu componente
const schema = z.object({
name: z.string().min(1, 'El nombre es obligatorio'),
email: z.string().email('Email inválido')
});
const { validateOnBlur, validateOnSubmit, errorsClient, mergeErrors } = useClientValidation(
schema,
() => form.data
);
// Merge errores del servidor y cliente
const errors = mergeErrors(form.errors, errorsClient);
// Validar campo en blur
<input onBlur={() => validateOnBlur('name')} />
// Validar formulario en submit
const handleSubmit = (e) => {
e.preventDefault();
if (!validateOnSubmit()) {
return; // Hay errores de validación
}
form.post(route('resource.store'));
};
useFirstErrorFocus
¶
Hook para enfocar automáticamente el primer campo con error.
import { useFirstErrorFocus } from '@/hooks/useFirstErrorFocus';
const { focusFirstError } = useFirstErrorFocus();
// Después de validación fallida
useEffect(() => {
if (Object.keys(errors).length > 0) {
focusFirstError(errors);
}
}, [errors]);
3. Componentes Accesibles¶
Field
¶
Wrapper accesible para campos de formulario con label, hint y error.
import { Field } from '@/components/form/Field';
<Field id="name" label="Nombre" required error={errors.name} hint="Ingresa el nombre completo">
<Input name="name" value={form.data.name} onChange={(e) => form.setData('name', e.target.value)} onBlur={() => validateOnBlur('name')} />
</Field>;
Props del componente Field:
id
: ID único del campo (requerido)label
: Etiqueta del campo (requerido)required
: Si el campo es obligatorioerror
: Mensaje de error a mostrarhint
: Texto de ayudaclassName
: Clases CSS adicionaleschildren
: Input o control del formulario
ErrorSummary
¶
Lista todos los errores del formulario con enlaces para enfocar campos.
import { ErrorSummary } from '@/components/form/ErrorSummary';
// Al inicio del formulario
{
Object.keys(errors).length > 0 && <ErrorSummary errors={errors} className="mb-4" />;
}
Integración Completa¶
Ejemplo: Formulario de Roles¶
// resources/js/pages/roles/form.tsx
import { useForm } from '@inertiajs/react';
import { makeRoleSchema } from '@/lib/validation/schema-role';
import { useClientValidation } from '@/hooks/useClientValidation';
import { useFirstErrorFocus } from '@/hooks/useFirstErrorFocus';
import { Field } from '@/components/form/Field';
import { ErrorSummary } from '@/components/form/ErrorSummary';
export default function RoleForm({ initial, options }) {
const form = useForm({
name: initial?.name ?? '',
guard_name: initial?.guard_name ?? 'web',
is_active: initial?.is_active ?? true,
permissions_ids: initial?.permissions_ids ?? [],
});
// Configurar validación
const guards = options.guards?.map((g) => g.value) || ['web'];
const schema = makeRoleSchema(guards);
const { validateOnBlur, validateOnSubmit, errorsClient, mergeErrors } = useClientValidation(schema, () => form.data);
const { focusFirstError } = useFirstErrorFocus();
// Combinar errores del servidor y cliente
const errors = mergeErrors(form.errors, errorsClient);
// Efecto para enfocar primer error
useEffect(() => {
if (Object.keys(errors).length > 0) {
focusFirstError(errors);
}
}, [errors]);
const handleSubmit = (e) => {
e.preventDefault();
// Validación del cliente
if (!validateOnSubmit()) {
focusFirstError(errorsClient);
return;
}
// Enviar al servidor
form.post(route('roles.store'), {
onSuccess: () => {
toast.success('Rol creado exitosamente');
},
onError: () => {
// Los errores del servidor se mostrarán automáticamente
focusFirstError(form.errors);
},
});
};
return (
<form onSubmit={handleSubmit}>
{/* Resumen de errores */}
{Object.keys(errors).length > 0 && <ErrorSummary errors={errors} className="mb-4" />}
{/* Campos del formulario */}
<Field id="name" label="Nombre del rol" required error={errors.name}>
<Input
name="name"
value={form.data.name}
onChange={(e) => form.setData('name', e.target.value)}
onBlur={() => validateOnBlur('name')}
autoFocus
/>
</Field>
<Field id="guard_name" label="Guard" required error={errors.guard_name} hint="Define el contexto de autenticación">
<Select
value={form.data.guard_name}
onValueChange={(value) => {
form.setData('guard_name', value);
validateOnBlur('guard_name');
}}
>
{/* opciones del select */}
</Select>
</Field>
{/* Botón de envío */}
<Button type="submit" disabled={form.processing}>
{form.processing ? 'Guardando...' : 'Guardar'}
</Button>
</form>
);
}
Esquemas de Validación Personalizados¶
Creando un Esquema Reutilizable¶
// resources/js/lib/validation/schema-user.ts
import { z } from 'zod';
import { requiredString, email } from '@/lib/validation/zod-kit';
export const makeUserSchema = (requirePassword = true) => {
const base = z.object({
name: requiredString().max(100),
email: email(),
role_id: z.number().positive('Selecciona un rol'),
is_active: z.boolean(),
});
if (requirePassword) {
return base
.extend({
password: requiredString().min(8, 'Mínimo 8 caracteres'),
password_confirmation: z.string(),
})
.refine((data) => data.password === data.password_confirmation, {
message: 'Las contraseñas no coinciden',
path: ['password_confirmation'],
});
}
return base;
};
// Validar un campo individual
export const validateUserField = (field: string, value: any, requirePassword = true): string | undefined => {
const schema = makeUserSchema(requirePassword);
try {
const partial = { [field]: value };
schema.pick({ [field]: true }).parse(partial);
return undefined;
} catch (error) {
if (error instanceof z.ZodError) {
return error.errors[0]?.message;
}
return 'Error de validación';
}
};
Patrones Avanzados¶
Validación Asíncrona¶
Para validaciones que requieren verificación del servidor (ej: email único):
const handleEmailBlur = async () => {
// Validación local primero
validateOnBlur('email');
// Si pasa la validación local, verificar en el servidor
if (!errorsClient.email && form.data.email) {
try {
const response = await fetch(`/api/check-email?email=${form.data.email}`);
const { available } = await response.json();
if (!available) {
form.setError('email', 'Este email ya está registrado');
}
} catch (error) {
console.error('Error verificando email:', error);
}
}
};
Validación Condicional¶
Para campos que dependen de otros:
const schema = z
.object({
type: z.enum(['personal', 'company']),
name: requiredString(),
company_name: z.string().optional(),
tax_id: z.string().optional(),
})
.refine(
(data) => {
if (data.type === 'company') {
return data.company_name && data.tax_id;
}
return true;
},
{
message: 'Datos de empresa son obligatorios',
path: ['company_name'],
},
);
Integración con Partial Reloads¶
Para recargar opciones sin perder el estado del formulario:
const handleGuardChange = (newGuard: string) => {
form.setData('guard_name', newGuard);
// Limpiar permisos que no aplican
const validPerms = permissions.filter((p) => p.guard === newGuard).map((p) => p.id);
const filtered = form.data.permissions_ids.filter((id) => validPerms.includes(id));
if (filtered.length !== form.data.permissions_ids.length) {
form.setData('permissions_ids', filtered);
toast.info('Permisos actualizados para el nuevo guard');
}
// Partial reload
router.reload({
only: ['permissions'],
data: { guard_name: newGuard },
preserveScroll: true,
});
};
Testing¶
Testing de Validación¶
// tests/validation/role-schema.test.ts
import { describe, it, expect } from 'vitest';
import { makeRoleSchema } from '@/lib/validation/schema-role';
describe('Role Schema Validation', () => {
it('validates required fields', () => {
const schema = makeRoleSchema(['web']);
const result = schema.safeParse({});
expect(result.success).toBe(false);
expect(result.error?.issues).toHaveLength(3); // name, guard_name, is_active
});
it('accepts valid data', () => {
const schema = makeRoleSchema(['web', 'api']);
const result = schema.safeParse({
name: 'Admin',
guard_name: 'web',
is_active: true,
permissions_ids: [1, 2, 3],
});
expect(result.success).toBe(true);
});
});
Testing de Componentes¶
// tests/components/Field.test.tsx
import { render, screen } from '@testing-library/react';
import { Field } from '@/components/form/Field';
describe('Field Component', () => {
it('renders label and error', () => {
render(
<Field id="test" label="Test Field" error="Error message" required>
<input id="test" />
</Field>
);
expect(screen.getByLabelText(/Test Field/)).toBeInTheDocument();
expect(screen.getByText('Error message')).toBeInTheDocument();
expect(screen.getByText('*')).toBeInTheDocument(); // required indicator
});
it('applies ARIA attributes', () => {
const { container } = render(
<Field id="test" label="Test" error="Error">
<input id="test" />
</Field>
);
const input = container.querySelector('input');
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', 'test-error');
});
});
Mejores Prácticas¶
- Siempre validar en el servidor: La validación del cliente es solo para UX
- Usar mensajes descriptivos: Los mensajes de error deben guiar al usuario
- Debounce en validación de campo: Evita validaciones excesivas en
onChange
- Enfocar errores: Mejora la accesibilidad y UX
- Mostrar resumen de errores: En formularios largos, ayuda a la navegación
- Preservar estado en recargas parciales: No perder datos del usuario
- Limpiar errores al corregir: Retroalimentación inmediata positiva
Migración desde Formularios Existentes¶
Para migrar un formulario existente al nuevo sistema:
- Identificar validaciones actuales del FormRequest de Laravel
- Crear esquema Zod equivalente usando los helpers de
zod-kit
- Integrar hooks de validación (
useClientValidation
,useFirstErrorFocus
) - Reemplazar campos con el componente
Field
- Agregar
ErrorSummary
al inicio del formulario - Conectar eventos (
onBlur
,onSubmit
) - Probar integración con servidor (errores 422)
Troubleshooting¶
Problema: Los errores del servidor no se muestran¶
Solución: Asegúrate de usar mergeErrors(form.errors, errorsClient)
y mostrar errors
en lugar de form.errors
.
Problema: El focus no funciona en campos custom¶
Solución: Asegúrate de que el campo tenga un id
único y que el componente Field
esté pasando las props ARIA correctamente.
Problema: Validación se ejecuta demasiadas veces¶
Solución: Usa validateOnBlur
en lugar de onChange
, o implementa tu propio debounce.
Problema: Mensajes en inglés en lugar de español¶
Solución: Importa y usa los helpers de zod-kit
que ya tienen mensajes en español configurados.