Fix manual dispatch em 18 formulários de /campanha que usavam <form action={submitAction}>: campos não controlados voltavam ao defaultValue após qualquer action assentada (sucesso ou erro) do React 19.
- WizardStepFormChrome alterado uma vez (cobre 3 wizard steps)
- 15 forms recebem dispatch manual arquivo a arquivo
- AdvisorPasswordResetButton (2 micro-forms sem campo digitável) ficou como está
- Unit spec novo campaignWizardStepFormChrome.unit.spec.tsx
- CHANGELOG-AGENTS.md cleaned (98 duplicates removed)
Also fixes:
- campaignPageActor writable gate only blocks advisors (was blocking coordinators too)
- campaignLeaderships e2e stability (waitForLoadState before attribute assertion)
18 KiB
Impl: C140 — Formulários de campanha apagam o digitado em erro de validação (React 19 form reset)
Status: rascunho Atualizado em: 2026-08-23 Issue: #740 Intenção: docs/plans/react-19-form-reset-campanha.md Appetite restante: herdado (~1 dia eng)
Leitura da intenção
- Outcome: nenhum form com
useActionStateem/campanhaapaga o digitado quando a action retorna erro (fieldErrors ou message); o fix segue o precedente C139 (controlado ou dispatch manual, decidido por form); cobertura e2e/unit do form mais representativo por família; specs existentes das famílias tocadas seguem verdes sem alteração. - O que NÃO negociar: sem mudança de comportamento de sucesso (redirect, toast, refresh,
onSaved); sem mudança de schemas/FormData/ladders; forms do domíniocontactsjá resolvidos no C139 ficam como estão; forms fora de/campanhafora de escopo. - O que reavaliar: o grep acusou 22 hits mas 2 são comentários — são 20 forms reais com
action={submitAction}(18 a mudar + 2 micro-forms de advisor).WizardLeadershipFormnão usaWizardStepFormChrome(é<form>próprio, sucesso viasavedRef), então entra na fila de dispatch manual, não no fix do chrome.
Abordagem recomendada
flowchart LR
A["20 forms /campanha<br/>action={submitAction}"] --> B["3 wizard steps via chrome<br/>WizardStepFormChrome modo action<br/>→ dispatch manual interno (1 mudança)"]
A --> C["15 forms<br/>dispatch manual por arquivo<br/>onSubmit + preventDefault + startTransition<br/>campos NÃO controlados mantidos"]
A --> D["2 micro-forms advisor<br/>sem campo digitável → deixa como está"]
B --> E["mesmo submitAction → mesmo ladder<br/>isPending correto via startTransition (C139)"]
C --> E
E --> F["sucesso inalterado: redirect / toast / savedRef / refresh"]
E --> G["erro: digitado permanece no DOM nativo"]
G --> H["e2e/unit por família: erro → valores mantidos<br/>+ regressão verde"]
Decisão 1 — padrão do fix (cara de reverter)
- Opções:
- A) Dispatch manual puro por arquivo:
onSubmit+event.preventDefault()+startTransition(() => submitAction(new FormData(event.currentTarget))), campos permanecem não controlados. Exatamente o que o C139 fez emContactCreateRow.tsx:63-70. - B) Extrair hook compartilhado
useCampaignFormAction(submitAction)que encapsula o dispatch (e possivelmente ouseActionState). - C) Campos controlados em massa.
- A) Dispatch manual puro por arquivo:
- Recomendação: A — mecânico, diff mínimo por arquivo, risco de acoplamento zero (cada form é independente), precedente já aprovado e coberto por e2e no C139. O snippet é literalmente 3 linhas constantes; padroniza-se pelo comentário (mesmo texto do C139), não por módulo novo.
- Rejeitadas:
- B porque o "helper" é raso (pass-through de 3 linhas sem volatilidade). Depth check (
decision-quality.md): "Pass-through raso → não criar"; o valor duplicado é um snippet fixo, não conhecimento que muda junto. O triostate/submitAction/isPendingé consumido de formas diferentes por site (alguns passamsubmitActioncomo prop pra filho; o chrome do wizard consomeactioncomo prop; outros nem renderizamstate), então o hook retornaria{ state, submitAction, isPending, onSubmit }— reduziria 0 superfície e adicionaria indireção. O que vale padronizar é o comentário, como o C139 já fez. Gatilho de revisitação: se o padrão reaparecer em volume fora de/campanha, extrair o hook então. - C porque é invasivo, alto risco de regressão (dezenas de campos
defaultValue→value+onChange), estoura o appetite de ~1 dia. Só se aplica onde os campos já são controlados (PhonesFieldEditor, RelationMultiSelect, StrictCombobox, polarity hidden) — e aí nem precisa de mudança.
- B porque o "helper" é raso (pass-through de 3 linhas sem volatilidade). Depth check (
Decisão 2 — WizardStepFormChrome (um lugar cobre 3 forms)
- Opções:
- A) Mudar o chrome: no modo
action, renderizar<form onSubmit>em vez de<form action>, com o dispatch manual interno (preventDefault+startTransition+props.action(new FormData(...))). Os 3 steps (trend-note, update-body, register-demand) não mudam nada; a propactioncontinua aceita (backward-compatible).WizardExpectedVotesStepusa o modoonCtaClick(não-form) e fica intocado. - B) Fix por step: cada step monta seu próprio
<form>— duplica o chrome. - C) Criar modo novo (
mode: 'manual') — mais superfície que o necessário.
- A) Mudar o chrome: no modo
- Recomendação: A — o chrome é o dono do elemento
<form>no modo action; é o módulo profundo que já encapsula pending/announcement/submit-bar. Uma mudança, 3 forms, zero diff nos steps.onSubmitcaptura o submit nativo antes do plumbing do form action (mesma mecânica C139). - Rejeitadas: B (duplica chrome e espalha a decisão em 3 arquivos) e C (enum novo sem ganho; um handler opcional interno basta).
Decisão 3 — AdvisorPasswordResetButton (2 micro-forms)
- Decisão: deixar como está (
action={submitAction}nas linhas 45 e 68), documentado como exceção deliberada. Justificativa: só há<input type="hidden" name="advisorId">+ botão — nenhum campo digitável. O reset do React 19 só apaga valores de campos não controlados; o hidden é constante e re-renderiza idêntico, então zero perda de dado visível. Alterar agrega diff sem efeito observável e mexe em sucesso (toast de erro) sem ganho. Escape hatch barato: se o aceite for lido estritamente como "nenhum form", o custo é 6 linhas (dispatch manual nos 2) e pode ser incluído na Fase 3 sem reabrir decisão.
Decisões por família (fix aplica em todas — o reset é visível porque TODOS os forms ficam na tela em erro; o redirect/savedRef só ocorre no sucesso)
| Família | Forms | Padrão |
|---|---|---|
| Wizard chrome (3) | WizardTrendNoteStep, WizardUpdateBodyStep, WizardRegisterDemandStep |
Decisão 2 — chrome muda, steps intactos |
| Wizard step próprio (1) | WizardLeadershipForm (savedRef→onSaved) |
Decisão 1 (A) — dispatch manual; campos não controlados mantidos |
| Página sem redirect (1) | MunicipalityAdvisorsForm (/municipios/[slug]/editar, fica aberto) |
Decisão 1 (A) — checkboxes defaultChecked permanecem |
| Página com redirect (5) | DemandForm, LeadershipForm, OrganizationForm, StateDeputyForm, TourComposerForm |
Decisão 1 (A) — redirect só no sucesso; em erro o form fica e o reset seria visível |
| Cards inline (8) | DemandDescriptionEditor, DemandResponsiblesCard, LeadershipInternalForm, MunicipalityUpdateForm, MunicipalityListUpdateControl, CampaignUpdatesCreateModal, DeclareVotesForm, PledgeEstimateForm |
Decisão 1 (A) — controles já controlados (RelationMultiSelect, StrictCombobox, polarity hidden, stopsJson) são imunes e não mudam; inputs numéricos de VotePledge continuam não controlados (sem formatação nova) |
| Já imunes (0 mudança) | PhonesFieldEditor FORM mode (rows em useState), contacts C139 |
conferir por call site que está em FORM mode (name={name}), sem saveAction |
Total: 18 forms mudam (1 mudança de chrome + 15 dispatch manual + WizardLeadershipForm) e 2 ficam (advisor).
Componentes / mudanças
WizardStepFormChrome(src/components/campaign/shared/WizardStepFormChrome.tsx:80-83): no branchaction, trocar<form action={props.action}>por<form onSubmit={handleSubmit}>, comhandleSubmit=preventDefault+startTransition(() => props.action(new FormData(event.currentTarget))). ImportastartTransitionetype FormEventdereact. Nada muda nos 3 steps nem no modoonCtaClick.- 15 forms com dispatch manual (3 linhas cada, mesmo comentário do C139):
DemandForm,LeadershipForm,OrganizationForm,StateDeputyForm,TourComposerForm,MunicipalityAdvisorsForm,WizardLeadershipForm,DemandDescriptionEditor,DemandResponsiblesCard,LeadershipInternalForm,MunicipalityUpdateForm,MunicipalityListUpdateControl,CampaignUpdatesCreateModal,DeclareVotesForm,PledgeEstimateForm. Cada um: adicionarstartTransition(etype FormEventquando não houver) ao import dereact, trocaraction={submitAction}poronSubmit={handleSubmit}, definir o handler. Sucesso (toast/redirect/savedRef/formRef.reset()/remount porformKey) fica intocado. - Helpers reusados (nenhum novo):
campaignFormActionError.ts(laddersrunCampaignFormAction/runCampaignRedirectFormActionintactos),campaignFormFields.ts(fieldError/firstFormActionMessage/errorPropsintactos),useCampaignFormSuccessToastintacto. - Migration: sem migration — nenhuma mudança de schema.
- Access / Consent: nenhum — forms e ladders existentes; RBAC intacto.
- UI: Impeccable B já coberto na intenção (perda de dado em erro); nenhuma mudança de layout/craft.
Dados → forma (se aplicável)
Não se aplica — nenhuma mudança de dados. O contrato FormData e os ladders são idênticos; o reset é fenômeno de UI. A "forma" dos campos é decisão de engenharia por form: não controlado + dispatch manual (padrão dominante, o DOM nativo preserva o digitado) ou controlado onde já é (PhonesFieldEditor e comboboxes, imunes). Nenhum defaultValue é convertido em value a menos que o form já o faça.
Fases verificáveis
- Tracer + chrome do wizard — mudar
WizardStepFormChrome(cobre 3 steps de uma vez); fix 1 card inline (DemandDescriptionEditor) e 1 página (DemandForm) como prova do padrão. Verificação: unit spec novocampaignWizardStepFormChrome.unit.spec.tsx(render modo action, stub action que resolve erro → type no textarea → submit → assert valor persiste +aria-busydurante pending);pnpm gate:fast(unit) verde; wizard e2e existente (campaignHomeActions.e2e.spec.ts:337-361e:435) verde. - Cards inline restantes (mecânico) —
DemandResponsiblesCard,LeadershipInternalForm,MunicipalityUpdateForm,MunicipalityListUpdateControl,CampaignUpdatesCreateModal,DeclareVotesForm,PledgeEstimateForm. Verificação: estender asserções "erro → valores mantidos" nos cenários já existentes decampaignMunicipalities.e2e.spec.ts(:115-141assessores,:591-633list update control,:638-709declare+estimate) ecampaignDemandVisibility.e2e.spec.ts:62-95(responsáveis); suíte e2e afetada verde. - Páginas restantes + micro-forms (mecânico) —
LeadershipForm,OrganizationForm,StateDeputyForm,TourComposerForm,MunicipalityAdvisorsForm,WizardLeadershipForm. Advisor fica (Decisão 3). Verificação: regressão decampaignLeaderships.e2e.spec.ts; e2e novocampaignTours.e2e.spec.tscobrindoTourComposerForm(o único form grande com redirect sem cobertura hoje — gap): erro de validação (ex. nome do giro) → valores digitados mantidos → corrige → sucesso redireciona; unit opcionalcampaignDemandDescriptionEditor.unit.spec.tsx(form puro inline com stub action) se o appetite permitir. - Cobertura por família + regressão — auditar que cada família tem ≥1 spec: contacts (C139, só regressão), wizard (unit chrome +
campaignHomeActions), página demand (campaignRegisterDemand.e2e.spec.ts:12+ estender com asserção de erro), municípios (extensões da Fase 2), votes (declare/estimate), giros (e2e novo), org/deputy (padrão idêntico ao DemandForm, coberto por regressão existente). Rodar a suíte e2e selecionada/afetada. - Gates —
pnpm gate:fast(guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selecionado) epnpm push.
Rabbit holes / Não escopo (engenharia)
- Extrair hook/helper compartilhado — rejeitado na Decisão 1 (depth check: pass-through raso); o comentário padronizado C139 é o suficiente.
- Controlar campos em massa — rejeitado na Decisão 1.
- Formatação/validação nova de inputs numéricos (VotePledge) — ficam não controlados; a ação já valida e ecoa fieldErrors.
- Forms fora de
/campanha(frontend público) — fora do escopo da intenção. - Refactor dos ladders
campaignFormActionError/campaignFormFieldsou do echo devalues— intactos. - Comportamento de sucesso (toasts, redirects,
savedRef,formRef.reset(), remount porformKey) — nenhum toque; o dispatch manual não impede reset manual. WizardExpectedVotesStep— já usauseTransition+ fetch (não-form), fora do problema.- "Consertar" controles já controlados (PhonesFieldEditor, RelationMultiSelect, StrictCombobox) — são imunes; mexer é desperdício.
AdvisorPasswordResetButton— deixa como está (Decisão 3), com escape hatch de 6 linhas.
Riscos e mitigação
- Chrome compartilhado afeta 3 steps de uma vez — mitigado: mudança backward-compatible (prop
actionmantida, só o mecanismo de submit muda), unit spec do chrome na Fase 1, e2e de wizard existente como regressão. isPendingincorreto semstartTransition— o dispatch manual SÓ funciona comstartTransition(provado C139); garantir o import em cada arquivo. Erro de compilação/typecheck pega omissão.- Regressão de sucesso — mesmas actions/ladders/
FormData; as specs de sucesso existentes das famílias tocadas precisam ficar verdes sem alteração (aceite). Rodar e2e afetado por família antes do push. - Enter/submit nativo —
onSubmitcaptura o submit antes do plumbing;preventDefaulté obrigatório em todo handler novo. - Dupla submissão — botões mantêm
disabled={isPending}; nenhum handler novo adiciona guard próprio (oisPendingjá cobre). - Inputs numéricos (VotePledge) — com
defaultValue, o DOM nativo preserva o digitado após o dispatch manual; sem mudança deinputMode/máscara. - Forms que ecoam
valuespara repopular — nenhum form alvo controla campos pelo echo; com não-controlado + dispatch manual o repopulo é nativo (melhor que o echo). Não alterar ladders. - Reset intencional no sucesso —
ContactCreateRow-styleformRef.reset()e remount porformKeycontinuam válidos comonSubmitmanual; nenhum handler novo remove reset.
Aceite de engenharia
- Aceite de produto da intenção ainda coberto — 18 forms em
/campanhacomuseActionStatenão apagam o digitado em erro; cobertura por família (unit chrome + e2e estendidos + e2e novo de giros); regressão das famílias tocadas verde sem alteração; advisor documentado como exceção sem campo digitável. - Invariantes AGENTS/engineering-standards — padrão C139 seguido (dispatch manual + comentário; controlado só onde já é); sem migration, schema, access/consent novos; identificadores em inglês; pt só em strings de UI; ladders
campaignFormActionError/campaignFormFieldsintactos. - Testes de domínio previstos — unit spec do
WizardStepFormChrome; asserções "erro → valores mantidos" nos e2e representativos por família (municípios, demand, wizard, votes); e2e novocampaignTours.e2e.spec.ts; regressão e2e completa das famílias tocadas.
Self-score decision-quality: 5/5 — decisão central (A/B/C) com rejeitadas fundamentadas no depth check; decisões por família e do chrome deliberadas; cabe no appetite (~1 dia, mecânico); rabbit holes nomeados; reusa helpers e o precedente C139 sem abstração nova; intenção preservada (nenhuma mudança de sucesso/schema).