0005 — Forms Subsystem (subsistema genérico de formulários)
Status
Accepted · 2026-04-26 · Autor: Alexandre + Claude.
Contexto
Plataforma tem dezenas de formulários: signup, login, criação de lead, questionário de onboarding, criação de proposta, edição de perfil, configuração de consultoria, solicitação Acolhe, etc.
Sem subsistema unificado, cada dev cria seu padrão. Resultado: 20 formulários com 20 jeitos de validar, mostrar erro, traduzir. Manutenção pesadelo.
Decisão
1. Stack canônico
react-hook-form(estado do form)- Zod (validação tipada, schemas reutilizáveis)
@clinicgestor/schemas(compartilhado entre front e edge functions)useFieldError(hook que resolve chave Zod → tradução PT-BR viauseT)
2. Schemas Zod com chaves i18n
import { z } from 'zod';import { emailField, passwordField, captchaTokenField } from '../common';
export const signInSchema = z.object({ email: emailField, password: passwordField, captchaToken: captchaTokenField,});
export type SignInInput = z.infer<typeof signInSchema>;Em common.ts:
export const emailField = z .string({ required_error: 'validation:email.required' }) .min(1, 'validation:email.required') .email('validation:email.invalid');
export const passwordField = z .string({ required_error: 'validation:password.required' }) .min(12, 'validation:password.too_short') .regex(/[a-z]/, 'validation:password.missing_lowercase') .regex(/[A-Z]/, 'validation:password.missing_uppercase') .regex(/[0-9]/, 'validation:password.missing_digit') .regex(/[^a-zA-Z0-9]/, 'validation:password.missing_symbol');
export const captchaTokenField = z .string({ required_error: 'validation:captcha.required' }) .min(1, 'validation:captcha.required');Mensagens são chaves i18n (namespace:key.subkey), nunca strings cruas.
3. useFieldError — resolve chave → tradução
export function useFieldError(error?: { message?: string }) { const { t } = useT(); // useT do @clinicgestor/i18n if (!error?.message) return undefined; // Se a mensagem é chave i18n (`ns:key`), traduz if (error.message.includes(':')) { return t(error.message); } // Senão é string crua (legado), retorna direto return error.message;}Uso em componente:
const { register, formState: { errors },} = useForm<SignInInput>({ resolver: zodResolver(signInSchema),});
const emailError = useFieldError(errors.email);
return ( <div> <Label htmlFor="email">Email</Label> <Input id="email" {...register('email')} aria-invalid={!!emailError} /> {emailError && <p className="text-danger text-sm">{emailError}</p>} </div>);4. mapAuthErrorKey — erros de auth API
Erros do Supabase Auth (e outras APIs) retornam mensagens em inglês não amigáveis. Mapeamos pra chaves i18n:
export function mapAuthErrorKey(error: AuthError): string { const code = error.code ?? error.message; switch (code) { case 'invalid_credentials': return 'auth:errors.invalid_credentials'; case 'email_not_confirmed': return 'auth:errors.email_not_confirmed'; case 'over_email_send_rate_limit': return 'auth:errors.rate_limit'; // ... default: return 'auth:errors.unknown'; }}Uso:
try { await supabase.auth.signInWithPassword({ email, password });} catch (err) { const errorKey = mapAuthErrorKey(err); toast.error(t(errorKey));}5. Engine de Forms genéricos (questionários, onboarding)
Pra forms dinâmicos (questionários do onboarding de lead, formulários de Programa Acolhe, etc), engine genérica em packages/forms (futuro):
// Spec de form em JSON{ "id": "lead_onboarding_v1", "fields": [ { "key": "clinic_size", "type": "select", "options": ["small", "medium", "large"], "required": true }, { "key": "specialty", "type": "text", "required": false }, { "key": "monthly_revenue_brl", "type": "number_brl", "required": true, "validation": { "min": 0 } }, ]}Engine renderiza, valida, submete. Permite Alexandre criar questionários sem dev.
(Em F1 podemos começar com schemas Zod hard-coded por questionário; engine genérica é F1.5+.)
6. Padrões obrigatórios
<form>comonSubmit={handleSubmit(onSubmit)}(RHF + Zod resolver)- Label sempre antes do input (acessibilidade)
aria-invalidquando erro- Botão “Salvar” disabled durante submit (
isSubmitting) - Confirmação visual (toast) após sucesso
- Schemas Zod reutilizáveis entre front e edge functions
Consequências
Positivas
- Consistência total entre formulários
- i18n correta sem hardcode
- Type-safe entre validação e API
- Reuso entre front e edge functions (mesmo schema)
- Acessibilidade built-in
Negativas
- Curva de aprendizado pra dev novo (precisa entender RHF + Zod + useFieldError)
- Schemas centralizados em
@clinicgestor/schemas— qualquer dev que mexe afeta múltiplos consumers
Riscos
- Mudança em schema centralizado quebra múltiplos formulários
- Mitigação: tests de schemas em
packages/schemas/src/<area>/schemas.test.ts
- Mitigação: tests de schemas em
- Engine genérica de forms (F1.5+) pode virar over-engineering
- Mitigação: começar com schemas Zod hard-coded; só adotar engine quando 5+ questionários parecidos justificarem
Alternativas consideradas
1. Formik
- Prós: maduro
- Contras: maior bundle; integração com Zod menos direta
- Rejeitado por preferência de RHF (mais leve, mais idiomático React).
2. Validação custom sem Zod
- Prós: zero dependência
- Contras: sem type inference; sem reuso back/front; sem composição
- Rejeitado.
3. Schemas duplicados (front e back)
- Prós: independência
- Contras: drift garantido; bug em produção esperando
- Rejeitado — schemas compartilhados é princípio.
Atualizações de documentos
- AGENTS.md §5.10 (i18n) e §5.11 (schemas)
docs/forms-subsystem.md(detalhes técnicos)
Referências
- AGENTS.md §5.10, §5.11
- Documentação react-hook-form e Zod