10 KiB
Impl: Escala/DRY: chrome comum dos passos finais do wizard (pós-B195)
Status: aprovado Atualizado em: 2026-08-11 Issue: #619 (D11) Intenção: docs/plans/escala-dry-pos-wizard-steps.md Appetite restante: herdado (~0,5–1 dia eng) — não estoura
Leitura da intenção
- Outcome: os 4 passos finais do wizard de
/campanha/acoesparam de duplicar (a) submit bar + pendência e (b) campos do cadastro de demanda, sem mudar comportamento, copy, toast/push/retorno ou a11y pinada por e2e. - O que NÃO negociar: copy intocada (
Salvando…, sr-only de cada passo, labels de CTA,WIZARD_*/CAMPAIGN_DEMAND_*continuam onde estão e com os mesmos valores); e2e de foco/a11y verdes (contentFocus, aria-live, label do botão); não renomear arquivos de steps (histórico de PRs); não unificar os corpos dos steps; nada de migration/schema. - O que reavaliar (hipóteses da intenção):
WizardStepFormChromecomo "form + pending chrome + submit bar" — o 4º step (WizardExpectedVotesStep) não é form: usauseTransition+fetch+ CTAtype="button", semuseActionState. Forçá-lo a form mudaria comportamento (Enter passaria a salvar). A extração precisa de dois modos, não de um form só.- "Campos de demanda parametrizado por prefixo de id +
showMunicipality" — a diferença entre os dois cadastros é maior que prefixo: município fixo (hidden input + search já vinculado) vs selecionável (gating do combobox, placeholder locked, reset de atividade, copy condicional, grid 2 colunas).
Abordagem recomendada
flowchart LR
A[WizardStepFormChrome<br/>shared/] --> B[WizardRegisterDemandStep]
A --> C[WizardUpdateBodyStep]
A --> D[WizardTrendNoteStep]
A --> E[WizardExpectedVotesStep]
F[DemandFields<br/>demand/] --> B
F --> G[DemandForm /demandas/nova]
WizardStepFormChrome — dono do chrome de passo final: wrapper (<form action> ou <div>), aria-busy/data-pending, submit bar com CTA (Spinner + Salvando… + label), região sr-only aria-live com pendingAnnouncement. Dois modos por união discriminada (type-safe, sem as/variant genérico): { action } (3 steps form) | { onCtaClick } (votos, preserva useTransition/fetch). Props de forma: leadingSubmit? (Limpar do trend), submitBarClassName? (flex-wrap do trend), ctaClassName? (largura).
DemandFields — dono dos campos do cadastro de demanda: Tipo (NativeSelect), Atividade (AsyncSearchCombobox), descrição (Textarea), erros via fieldError. Prop municipality? opcional: ausente = modo wizard (stacked, combobox sempre pronto, search já vinculado pelo passo); presente = modo /demandas/nova (grid 2 colunas Tipo+Município, gating isQueryReady/queryTooShortMessage, placeholder button locked, reset de atividade na troca de município, copy condicional da descrição). O gating/reset/copy condicional só disparam em modo município — no wizard o DOM sai idêntico ao de hoje.
Opções consideradas:
- Chrome: A) um componente com união discriminada
action | onCtaClick— recomendada; B) só-form nos 3 steps e votos mantém o bloco duplicado — rejeitada: o débito do /simplify renasce no passo 4 com o mesmo conhecimento; C)variant/asgenérico — rejeitada: mesma cobertura com API mais frouxa e modos não checados por tipo. - Campos: A)
DemandFieldscommunicipality?opcional — recomendada; B) campos comuns sem município + DemandForm mantém o select/gating próprios — rejeitada: o Field de atividade (combobox vs placeholder locked) é inseparável do estado do município; deixar metade fora re-duplica; C) DemandForm vira dono e o wizard o consome inteiro — rejeitada: layout (max-w-2xl gap-4vsgap-6), ids,disabled={isPending}e estado diferem; mudaria o DOM e o comportamento do wizard.
Componentes / mudanças
WizardStepFormChrome(src/components/campaign/shared/WizardStepFormChrome.tsx): novo. Props comunsisPending,pendingAnnouncement,ctaLabel,children,leadingSubmit?,submitBarClassName?,ctaClassName?; união{ action: CampaignFormAction } | { onCtaClick: () => void }. Renderiza<form action>ou<div>comclassName="flex flex-col gap-6",aria-busy={isPending || undefined},data-pending; bar defaultflex items-center justify-end; CTAmin-h-11+min-w-[7rem]default +<Spinner data-icon="inline-start" aria-hidden="true" />+Salvando…; sr-onlyaria-live="polite"por último.Salvando…passa a viver aqui (1 site).DemandFields(src/components/campaign/demand/DemandFields.tsx): novo. PropsidPrefix,disabled?,state: CampaignFormActionState,activity,onActivityChange,searchActivities,municipality?. ReusafieldError(utilities/campaignFormFields), consts delib/schemas/campaignDemand(B195, intocadas). Layout:municipalityausente → campos empilhados (hoje do wizard); presente →grid gap-4 sm:grid-cols-2com Tipo+Município, e gating de atividade.WizardRegisterDemandStep(shared/): passa a comporWizardStepFormChrome(modoaction,WIZARD_DEMAND_PENDING_ARIA,CAMPAIGN_DEMAND_SUBMIT_LABEL,ctaClassName="min-w-[8rem]") +DemandFields(prefixowizard-demand-,disabled={isPending}). Mantém hiddenmunicipalityId,searchActivitiesvinculado aomunicipalityIdprop, toast/push/recordLastActedMunicipality, hidden input eCampaignFormActionMessagecomo children.WizardUpdateBodyStep(municipality/): compõeWizardStepFormChrome(modoaction, sr-onlySalvando atualização.,WIZARD_UPDATE_SAVE_LABEL, largura default). Hidden inputs, corpo (textarea/polaridade/urgente/adversary),CampaignFormActionMessagecomo children.WizardTrendNoteStep(municipality/): idem +leadingSubmit= botão Limpar (WIZARD_TREND_CLEAR_LABEL,disabledpornote.length === 0) +submitBarClassName="flex-wrap gap-3".autoFocusda textarea intocado.WizardExpectedVotesStep(shared/): modoonCtaClick={handleConfirm}(mesma validaçãogetWizardVoteViolation, transition epostCampaignJson),pendingAnnouncement="Salvando votos estimados.",ctaLabel={WIZARD_VOTES_FINAL_CTA_LABEL},ctaClassName="w-full". Corpo (inputs, shortcuts, alerts) como children. Micro-diferenças conscientes:aria-busyomitefalseno idle (hoje votos emitearia-busy="false") e sr-only passa a vir depois do CTA — sem efeito perceptível.DemandForm(demand/): passa a comporDemandFieldscommunicipalityconfig (options/value/onValueChange); perde o select, o gating, o reset e a copy condicional próprios. Mantém<form>próprio (max-w-2xl gap-4,CampaignFormActionMessage, botãoself-startcom Spinner +CAMPAIGN_DEMAND_SUBMIT_LABEL) — não é passo de wizard, não entra no chrome.- Migration: nenhuma. Access/Consent: nenhum. UI: extração mecânica com DOM equivalente (não é Impeccable B/C/D; é refactor). Dados → forma: n/a.
Fases verificáveis
- Chrome: criar
WizardStepFormChrome+ refatorar os 4 steps (semDemandFieldsainda).pnpm gate:fast+ e2e:campaignHomeActions,campaignWizardChrome,campaignRegisterDemand(foco, aria-live, labels). - Campos: criar
DemandFields+ refatorarDemandFormeWizardRegisterDemandStep.pnpm gate:fast+ e2ecampaignRegisterDemand+campaignMunicipalities(caminho /demandas/nova),campaignUpdatesMobile(form de update no mobile). - Gates totais e fechamento:
pnpm gate:fast+pnpm push(lint 0 warnings, format, knip — imports órfãos dos steps morrem na entrega,check:cycles, unit+int, build) →/simplify→capture-review-debtscom gate humano → PR Ready + auto-merge.
Rabbit holes / Não escopo (engenharia)
- Não unificar corpos dos steps (cada um tem campos próprios) — só o chrome/pending e os campos de demanda.
- Não renomear arquivos de steps nem
CampaignWizardShell/DemandForm. - Não mexer em copy:
WIZARD_UPDATE_*vsWIZARD_DEMAND_*continua deferido (cosmético, gatilho: próxima casa de copy). - Não transformar
WizardExpectedVotesStepem<form>(Enter salvaria — mudança de comportamento). - Não mover hidden inputs nem
CampaignFormActionMessagepara dentro do chrome (cada passo decide os seus).
Já resolvido no simplify (não reabrir)
- Duplicação interna do chrome (branches form/div) → hoist de
contentemWizardStepFormChrome. - Kind field duplicado inline → hoist de
kindFieldemDemandFields. - Micro-mudanças conscientes (documentadas): votos omite
aria-busyno idle (vs"false"), Spinner do votos ganhadata-icon="inline-start"(~4px no pending), sr-only do votos passa a vir após o CTA,requiredinerte no kind select doDemandForm,fieldErrornormaliza 1º erro (produtores emitem ≤1).
Explicitamente fora (descartes/defer deste triage)
- Flake e2e
getByLabel('Atividade relacionada')strict mode → registrado como D12 (Issue #641, pré-existente em main — provado via stash; não é débito do D11). - Defer com gatilho (do plano de intenção, mantidos): fail-closed
campaignDemandUniqueSlugsem teste (gatilho: próxima mudança no hook); copy "Informe um título com letras ou números." (gatilho: feedback em campo); casa de copyWIZARD_UPDATE_*vsWIZARD_DEMAND_*(gatilho: próxima casa de copy);canLeaderEditmorto (pré-existente); update de demanda com descrição inalterada ainda grava (documentado em teste).
Riscos e mitigação
- Regressão de a11y/foco nos steps legados → e2e
campaignHomeActions/campaignWizardChrome/campaignRegisterDemandpinamcontentFocus, aria-live e labels; rodar nas fases 1 e 2. - Mudança visual acidental no wizard → o chrome em modo form reproduz o DOM exato de hoje (mesmas classes, mesma ordem de children); conferir por diff dos 4 steps.
DemandFieldsvirar config soup → cada prop mapeia uma diferença real entre os 2 call sites existentes; nenhum prop especulativo; se um 3º cadastro surgir, o dono já está definido.- Imports órfãos nos steps (Spinner/Button/NativeSelect removidos) → knip no gate final aponta; remover na entrega (não
knip --fixcego).
Aceite de engenharia
- Aceite de produto da intenção coberto (extração sem mudar copy/comportamento/toast/retorno)
- Invariantes AGENTS/engineering-standards (sem migration, sem access, sem transação, identificadores em inglês)
- Testes: e2e dos 3 specs de wizard + demandas nas fases; gates completos no fechamento
- Depth check:
CampaignWizardShell, consts B195 efieldErrorreusados; os 2 módulos novos são os donos das 2 duplicações