Saltar a contenido

Formularios

El paso 8 del protocolo. Con "zod": true, <schema>/zod.ts trae un esquema zod 4 por cada Create y cada UpdateValues, con las reglas de la API: los obligatorios, los que admiten null, los valores de cada enumerado, las fechas y la longitud de los textos. Son esquemas de zod, sin nada de ningún framework.

Son opcionales

Nada del cliente exige usarlos: api.public.lote.create(valores) acepta un objeto normal con su tipo (LoteCreate). Son una ayuda para no reescribir a mano las reglas que ya tiene la API.

En el formulario

Con react-hook-form:

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { LoteCreateSchema } from '@/lib/api/public/zod'

const form = useForm({ resolver: zodResolver(LoteCreateSchema) })

Con el esquema directamente, sin librería de formularios:

<script lang="ts">
  import { z } from 'zod'
  import { LoteCreateSchema } from '$lib/api/public/zod'

  let valores = $state({ codigo: '', cantidad: 0 })
  let errores = $state<Record<string, string[] | undefined>>({})

  async function guardar(evento: SubmitEvent) {
    evento.preventDefault()
    const resultado = LoteCreateSchema.safeParse(valores)
    if (!resultado.success) {
      errores = z.flattenError(resultado.error).fieldErrors
      return
    }
    await api.public.lote.create(resultado.data)
  }
</script>

Si usáis una librería de formularios, la mayoría acepta un esquema de zod: el generado se le pasa tal cual.

const resultado = LoteCreateSchema.safeParse(valores)
if (resultado.success) await api.public.lote.create(resultado.data)

Un formulario no es la API

Lo que valida un formulario no siempre es lo que se envía: hay campos que el usuario no ve, otros que se calculan antes de enviar, y validaciones que solo tienen sentido en pantalla («repite la contraseña»). Por eso el esquema generado es el punto de partida, no el formulario:

import { z } from 'zod'
import { LoteCreateSchema } from '@/lib/api/public/zod'

// Solo los campos que el usuario rellena, y más estrictos que la API
export const LoteFormSchema = LoteCreateSchema
  .pick({ codigo: true, fecha_caducidad: true, cantidad: true })
  .extend({
    codigo: z.string().min(3, 'Mínimo 3 caracteres'),
    cantidad: z.number().positive('Tiene que ser mayor que cero'),
  })

Un campo opcional en la API no da error si falta: si en el formulario es obligatorio, se hace obligatorio en el esquema del formulario, como arriba.

Lo que validan, y lo que no

Validan No validan
Qué campos son obligatorios La unicidad: solo la sabe la base de datos (llega un 409)
Qué campos admiten null Las claves foráneas: que el producto exista (409)
Los valores de un enumerado Las relaciones anidadas: ver abajo
Que una fecha sea ISO: datetime sin zona, date, time
La longitud de un texto con column(length=n)

Lo que no valida el esquema llega de la API como un TaiApiError, y error.fieldErrors dice qué campo falló, para ponerlo en el formulario:

crear.mutate(valores, {
  onError: (e) => {
    if (isTaiApiError(e)) for (const [campo, mensaje] of Object.entries(e.fieldErrors)) form.setError(campo, { message: mensaje })
  },
})
try {
  await api.public.lote.create(resultado.data)
} catch (e) {
  if (isTaiApiError(e)) errores = Object.fromEntries(Object.entries(e.fieldErrors).map(([c, m]) => [c, [m]]))
}
if (isTaiApiError(e)) e.fieldErrors   // { campo: mensaje }

Altas y ediciones anidadas

Un formulario que edita un registro con sus hijos —una granja con sus naves y sus silos— se envía de una vez. El tipo UpdateValues de la tabla padre lleva un campo por relación con lo que hacer con los hijos:

await api.public.granja.update(granjaId, {
  nombre: 'La Vega',
  naves: {
    create: [{ nombre: 'Nave 4', capacidad: 1200 }],
    update: [{ id: 12, values: { capacidad: 1500 } }],
    delete: [9],                               // por su clave; con una compuesta, un objeto
  },
})

Y al crear, GranjaCreate acepta sus hijos directamente: { nombre, naves: [{ … }, { … }] }. Todo va en una transacción: si algo falla, no se guarda nada.

Cada hijo pide el permiso de su tabla: el ejemplo pide granja-update, nave-create, nave-update y nave-delete, y aplica el RLS y el propietario de nave. Sin uno de ellos la respuesta es un 403 cuyo field dice qué hijo (naves.delete.0) y details.required_permission, qué permiso; un hijo que el usuario no alcanza es un 404. Hasta tai-api 0.6.2 bastaba el permiso del padre: ver Escrituras anidadas.

Los esquemas zod cubren las columnas de cada tabla, no sus hijos: para un formulario anidado se compone uno por tabla (GranjaUpdateValuesSchema y NaveCreateSchema).