Merge pull request #647 from fsolla/D11-escala-dry-chrome-comum-dos-passos-finais-do-wizard-pos-

D11 — Escala/DRY: chrome comum dos passos finais do wizard (pós-B195)
This commit is contained in:
Francisco Solla 2026-08-11 10:59:13 +00:00 committed by GitHub
commit 0a9da04c92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 451 additions and 296 deletions

View file

@ -0,0 +1,36 @@
# E2E: flake do `getByLabel('Atividade relacionada')` (strict mode) sob carga
Status: rascunho
Atualizado em: 2026-08-11
Issue: #619 (D11) colheu; registro D12
Priority: P3
Kind: defect
Depends: [] — pré-existente em main, não nasce do D11
## O sintoma
`tests/e2e/campaignRegisterDemand.e2e.spec.ts:38` — `expect(page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL)).toBeVisible()` (label `Atividade relacionada`) falha com **strict mode violation: resolved to 2 elements**, ambos botões com `aria-label="Atividade relacionada: Nenhuma atividade"` (o trigger do `AsyncSearchCombobox`).
## Caracterização (colhida no D11, 2026-08-11)
- **Falha em runs completos, passa isolado:** 3/4 runs completos do set (dev e prod/E2E_PROD=1) falharam; o teste isolado passa sempre (dev e prod).
- **Pré-existente:** `git stash` no worktree do D11 (código 100% main) reproduziu a falha **idêntica** no mesmo teste — inclusive o mesmo diretório de error-context. Não é regressão do D11.
- **Ambiente:** máquina do pool com carga alta (compiles dev de 6–27s; asserts com budget 10s). O erro aparece como violação de strict mode em `getByLabel`, que é substring-match: casam o trigger button (aria-label `Atividade relacionada: …`) e um segundo nó.
- **Snapshot pós-falha:** o form renderizado mostra um único trigger e o conteúdo do `CommandDialog` (título + descrição) montado no DOM com o dialog fechado — o segundo match é transitório/estado de reconciliação, não duas instâncias estáveis do componente.
## Hipóteses a investigar (sem implementação aqui)
1. `AsyncSearchCombobox` renderiza `CommandDialog` com conteúdo sempre montado: `CommandInput` tem `aria-label={label}` (o label exato, sem sufixo) — candidato natural a segundo match quando visível à a11y tree (possível `forceMount`/transição do Radix/cmdk).
2. Mesmo nó resolvido duas vezes por caminhos de label distintos (aria-label + associação via `<label>`/`aria-labelledby`) — validar com `page.getByLabel(...).evaluateAll(n => n.length)` num state aberto vs fechado.
3. Timing: o match duplicado existe durante a transição de abertura/fechamento do dialog ou numa re-render em rajada (2 workers + navegação pesada) — por isso só aparece em runs completos.
## Direções de fix (escolher na execução)
- **Spec:** `getByLabel` → `getByRole('button', { name: /Atividade relacionada:/ })` (o trigger) ou `.first()`; ou `getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL, { exact: false })` com alvo inequívoco.
- **Componente:** remover redundância de labels (ex.: `aria-label` do `CommandInput` só quando aberto; ou `aria-hidden` do dialog fechado).
- Não alterar copy visível nem o comportamento da combobox.
## Aceite
- [ ] `campaignRegisterDemand` verde em run completo (reprodução ×3) e isolado
- [ ] Sem mudança de DOM/labels no estado fechado que afete outros specs (grep `getByLabel` em e2e)

View file

@ -0,0 +1,86 @@
# 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/acoes` param 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):**
1. `WizardStepFormChrome` como "form + pending chrome + submit bar" — o 4º step (`WizardExpectedVotesStep`) **não é form**: usa `useTransition` + `fetch` + CTA `type="button"`, sem `useActionState`. Forçá-lo a form mudaria comportamento (Enter passaria a salvar). A extração precisa de dois modos, não de um form só.
2. "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
```mermaid
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`/`as` genérico — rejeitada: mesma cobertura com API mais frouxa e modos não checados por tipo.
- **Campos:** A) `DemandFields` com `municipality?` 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-4` vs `gap-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 comuns `isPending`, `pendingAnnouncement`, `ctaLabel`, `children`, `leadingSubmit?`, `submitBarClassName?`, `ctaClassName?`; união `{ action: CampaignFormAction } | { onCtaClick: () => void }`. Renderiza `<form action>` ou `<div>` com `className="flex flex-col gap-6"`, `aria-busy={isPending || undefined}`, `data-pending`; bar default `flex items-center justify-end`; CTA `min-h-11` + `min-w-[7rem]` default + `<Spinner data-icon="inline-start" aria-hidden="true" />` + `Salvando…`; sr-only `aria-live="polite"` por último. `Salvando…` passa a viver aqui (1 site).
- **`DemandFields`** (`src/components/campaign/demand/DemandFields.tsx`): novo. Props `idPrefix`, `disabled?`, `state: CampaignFormActionState`, `activity`, `onActivityChange`, `searchActivities`, `municipality?`. Reusa `fieldError` (`utilities/campaignFormFields`), consts de `lib/schemas/campaignDemand` (B195, intocadas). Layout: `municipality` ausente → campos empilhados (hoje do wizard); presente → `grid gap-4 sm:grid-cols-2` com Tipo+Município, e gating de atividade.
- **`WizardRegisterDemandStep`** (`shared/`): passa a compor `WizardStepFormChrome` (modo `action`, `WIZARD_DEMAND_PENDING_ARIA`, `CAMPAIGN_DEMAND_SUBMIT_LABEL`, `ctaClassName="min-w-[8rem]"`) + `DemandFields` (prefixo `wizard-demand-`, `disabled={isPending}`). Mantém hidden `municipalityId`, `searchActivities` vinculado ao `municipalityId` prop, toast/push/`recordLastActedMunicipality`, hidden input e `CampaignFormActionMessage` como children.
- **`WizardUpdateBodyStep`** (`municipality/`): compõe `WizardStepFormChrome` (modo `action`, sr-only `Salvando atualização.`, `WIZARD_UPDATE_SAVE_LABEL`, largura default). Hidden inputs, corpo (textarea/polaridade/urgente/adversary), `CampaignFormActionMessage` como children.
- **`WizardTrendNoteStep`** (`municipality/`): idem + `leadingSubmit` = botão Limpar (`WIZARD_TREND_CLEAR_LABEL`, `disabled` por `note.length === 0`) + `submitBarClassName="flex-wrap gap-3"`. `autoFocus` da textarea intocado.
- **`WizardExpectedVotesStep`** (`shared/`): modo `onCtaClick={handleConfirm}` (mesma validação `getWizardVoteViolation`, transition e `postCampaignJson`), `pendingAnnouncement="Salvando votos estimados."`, `ctaLabel={WIZARD_VOTES_FINAL_CTA_LABEL}`, `ctaClassName="w-full"`. Corpo (inputs, shortcuts, alerts) como children. Micro-diferenças conscientes: `aria-busy` omite `false` no idle (hoje votos emite `aria-busy="false"`) e sr-only passa a vir depois do CTA — sem efeito perceptível.
- **`DemandForm`** (`demand/`): passa a compor `DemandFields` com `municipality` config (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ão `self-start` com 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
1. **Chrome:** criar `WizardStepFormChrome` + refatorar os 4 steps (sem `DemandFields` ainda). `pnpm gate:fast` + e2e: `campaignHomeActions`, `campaignWizardChrome`, `campaignRegisterDemand` (foco, aria-live, labels).
2. **Campos:** criar `DemandFields` + refatorar `DemandForm` e `WizardRegisterDemandStep`. `pnpm gate:fast` + e2e `campaignRegisterDemand` + `campaignMunicipalities` (caminho /demandas/nova), `campaignUpdatesMobile` (form de update no mobile).
3. **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-debts` com 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_*` vs `WIZARD_DEMAND_*` continua deferido (cosmético, gatilho: próxima casa de copy).
- Não transformar `WizardExpectedVotesStep` em `<form>` (Enter salvaria — mudança de comportamento).
- Não mover hidden inputs nem `CampaignFormActionMessage` para 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 `content` em `WizardStepFormChrome`.
- Kind field duplicado inline → hoist de `kindField` em `DemandFields`.
- Micro-mudanças conscientes (documentadas): votos omite `aria-busy` no idle (vs `"false"`), Spinner do votos ganha `data-icon="inline-start"` (~4px no pending), sr-only do votos passa a vir após o CTA, `required` inerte no kind select do `DemandForm`, `fieldError` normaliza 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 `campaignDemandUniqueSlug` sem teste (gatilho: próxima mudança no hook); copy "Informe um título com letras ou números." (gatilho: feedback em campo); casa de copy `WIZARD_UPDATE_*` vs `WIZARD_DEMAND_*` (gatilho: próxima casa de copy); `canLeaderEdit` morto (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`/`campaignRegisterDemand` pinam `contentFocus`, 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.
- **`DemandFields` virar 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 --fix` cego).
## Aceite de engenharia
- [x] Aceite de produto da intenção coberto (extração sem mudar copy/comportamento/toast/retorno)
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access, sem transação, identificadores em inglês)
- [x] Testes: e2e dos 3 specs de wizard + demandas nas fases; gates completos no fechamento
- [x] Depth check: `CampaignWizardShell`, consts B195 e `fieldError` reusados; os 2 módulos novos são os donos das 2 duplicações

View file

@ -0,0 +1,175 @@
'use client'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import {
CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
CAMPAIGN_DEMAND_ACTIVITY_LABEL,
CAMPAIGN_DEMAND_BODY_DESCRIPTION,
CAMPAIGN_DEMAND_BODY_LABEL,
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
CAMPAIGN_DEMAND_KIND_LABEL,
campaignDemandKindLabels,
campaignDemandKinds,
} from '@/lib/schemas/campaignDemand'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
export type DemandActivityValue = { id: number; label: string }
type DemandFieldsProps = {
idPrefix: string
disabled?: boolean
state: CampaignFormActionState
activity: DemandActivityValue | null
onActivityChange: (value: DemandActivityValue | null) => void
searchActivities: (query: string) => Promise<DemandActivityValue[]>
/** Present on `/demandas/nova`: the municipality selector gates the activity search. */
municipality?: {
options: RelationOption[]
value: string
onValueChange: (value: string) => void
}
}
/**
* Shared demand-create fields (D11): Tipo, Atividade and the single free-text
* description, used by the wizard final step (fixed municipality, stacked) and
* by `DemandForm` (selectable municipality, two-column grid).
*/
export const DemandFields = ({
idPrefix,
disabled,
state,
activity,
onActivityChange,
searchActivities,
municipality,
}: DemandFieldsProps) => {
const municipalityReady = municipality ? municipality.value !== '' : true
const activityField = (
<Field>
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
{municipality && !municipalityReady ? (
<Button
type="button"
variant="outline"
disabled
className="min-h-11 w-full justify-between font-normal opacity-70"
>
Selecione o município primeiro…
</Button>
) : (
<AsyncSearchCombobox
name="activityId"
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
value={activity}
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
isQueryReady={municipality ? () => municipalityReady : undefined}
queryTooShortMessage={
municipality ? 'Selecione o município para buscar atividades.' : undefined
}
search={searchActivities}
onChange={onActivityChange}
/>
)}
<FieldDescription>
{municipality && !municipalityReady
? 'Opcional. Escolha o município antes de vincular uma atividade.'
: CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION}
</FieldDescription>
{fieldError(state.fieldErrors, 'activity') ? (
<FieldError>{fieldError(state.fieldErrors, 'activity')}</FieldError>
) : null}
</Field>
)
const kindField = (
<Field>
<FieldLabel htmlFor={`${idPrefix}-kind`}>{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
<NativeSelect
id={`${idPrefix}-kind`}
name="kind"
defaultValue="material"
required
disabled={disabled}
className={cn('min-h-11 w-full', !municipality && 'sm:w-56')}
>
{campaignDemandKinds.map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{campaignDemandKindLabels[kind]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
)
return (
<>
{municipality ? (
<div className="grid gap-4 sm:grid-cols-2">
{kindField}
<Field>
<FieldLabel htmlFor={`${idPrefix}-municipality`}>Município</FieldLabel>
<NativeSelect
id={`${idPrefix}-municipality`}
name="municipalityId"
required
value={municipality.value}
onChange={(event) => {
municipality.onValueChange(event.target.value)
onActivityChange(null)
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="" disabled>
Selecione o município…
</NativeSelectOption>
{municipality.options.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
{fieldError(state.fieldErrors, 'municipality') ? (
<FieldError>{fieldError(state.fieldErrors, 'municipality')}</FieldError>
) : null}
</Field>
</div>
) : (
kindField
)}
{activityField}
<Field>
<FieldLabel htmlFor={`${idPrefix}-description`}>{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
<Textarea
id={`${idPrefix}-description`}
name="description"
rows={5}
minLength={2}
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
required
disabled={disabled}
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
className="min-h-28"
/>
{fieldError(state.fieldErrors, 'description') ? (
<FieldError>{fieldError(state.fieldErrors, 'description')}</FieldError>
) : null}
</Field>
</>
)
}

View file

@ -2,31 +2,14 @@
import { useActionState, useCallback, useState } from 'react'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
CAMPAIGN_DEMAND_ACTIVITY_LABEL,
CAMPAIGN_DEMAND_BODY_DESCRIPTION,
CAMPAIGN_DEMAND_BODY_LABEL,
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
CAMPAIGN_DEMAND_KIND_LABEL,
CAMPAIGN_DEMAND_SUBMIT_LABEL,
campaignDemandKindLabels,
campaignDemandKinds,
} from '@/lib/schemas/campaignDemand'
import { CAMPAIGN_DEMAND_SUBMIT_LABEL } from '@/lib/schemas/campaignDemand'
import type { ActivityRelationOption } from '@/utilities/activityRelationOptions'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
type DemandFormProps = {
municipalityOptions: RelationOption[]
@ -53,7 +36,7 @@ export const DemandForm = ({
const [municipalityId, setMunicipalityId] = useState(
initialMunicipalityId ? String(initialMunicipalityId) : '',
)
const [activity, setActivity] = useState<{ id: number; label: string } | null>(
const [activity, setActivity] = useState<DemandActivityValue | null>(
initialActivity ? { id: initialActivity.id, label: initialActivity.label } : null,
)
@ -62,107 +45,21 @@ export const DemandForm = ({
(query: string) => searchActivities(query, parsedMunicipalityId),
[parsedMunicipalityId, searchActivities],
)
const activityQueryReady = useCallback(
() => parsedMunicipalityId !== null,
[parsedMunicipalityId],
)
const activityFieldDescription = parsedMunicipalityId
? CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION
: 'Opcional. Escolha o município antes de vincular uma atividade.'
return (
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="demand-kind">{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
<NativeSelect
id="demand-kind"
name="kind"
defaultValue="material"
className="min-h-11 w-full"
>
{campaignDemandKinds.map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{campaignDemandKindLabels[kind]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="demand-municipality">Município</FieldLabel>
<NativeSelect
id="demand-municipality"
name="municipalityId"
required
value={municipalityId}
onChange={(event) => {
setMunicipalityId(event.target.value)
setActivity(null)
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="" disabled>
Selecione o município…
</NativeSelectOption>
{municipalityOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
{fieldError(state.fieldErrors, 'municipality') ? (
<FieldError>{fieldError(state.fieldErrors, 'municipality')}</FieldError>
) : null}
</Field>
</div>
<Field>
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
{parsedMunicipalityId ? (
<AsyncSearchCombobox
name="activityId"
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
value={activity}
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
isQueryReady={activityQueryReady}
queryTooShortMessage="Selecione o município para buscar atividades."
search={searchActivitiesForMunicipality}
onChange={setActivity}
/>
) : (
<Button
type="button"
variant="outline"
disabled
className="min-h-11 w-full justify-between font-normal opacity-70"
>
Selecione o município primeiro…
</Button>
)}
<FieldDescription>{activityFieldDescription}</FieldDescription>
{fieldError(state.fieldErrors, 'activity') ? (
<FieldError>{fieldError(state.fieldErrors, 'activity')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="demand-description">{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
<Textarea
id="demand-description"
name="description"
rows={5}
minLength={2}
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
required
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
className="min-h-28"
/>
{fieldError(state.fieldErrors, 'description') ? (
<FieldError>{fieldError(state.fieldErrors, 'description')}</FieldError>
) : null}
</Field>
<DemandFields
idPrefix="demand"
state={state}
activity={activity}
onActivityChange={setActivity}
searchActivities={searchActivitiesForMunicipality}
municipality={{
options: municipalityOptions,
value: municipalityId,
onValueChange: setMunicipalityId,
}}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}

View file

@ -7,8 +7,8 @@ import { setMunicipalityPoliticalTrendFormAction } from '@/app/(campaign)/campan
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
@ -62,11 +62,23 @@ export const WizardTrendNoteStep = ({
municipalityLabel={municipalityName}
contentFocus="none"
>
<form
<WizardStepFormChrome
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
isPending={isPending}
pendingAnnouncement="Salvando tendência."
ctaLabel={WIZARD_TREND_SAVE_LABEL}
leadingSubmit={
<Button
type="button"
variant="outline"
disabled={isPending || note.length === 0}
className="min-h-11"
onClick={() => setNote('')}
>
{WIZARD_TREND_CLEAR_LABEL}
</Button>
}
submitBarClassName="flex-wrap gap-3"
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
@ -91,33 +103,7 @@ export const WizardTrendNoteStep = ({
</div>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex flex-wrap items-center justify-end gap-3">
<Button
type="button"
variant="outline"
disabled={isPending || note.length === 0}
className="min-h-11"
onClick={() => setNote('')}
>
{WIZARD_TREND_CLEAR_LABEL}
</Button>
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
WIZARD_TREND_SAVE_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando tendência.' : null}
</div>
</form>
</WizardStepFormChrome>
</CampaignWizardShell>
)
}

View file

@ -7,11 +7,10 @@ import { createMunicipalityUpdateFormAction } from '@/app/(campaign)/campanha/(a
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
@ -60,11 +59,11 @@ export const WizardUpdateBodyStep = ({
municipalityLabel={municipalityName}
contentFocus="none"
>
<form
<WizardStepFormChrome
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
isPending={isPending}
pendingAnnouncement="Salvando atualização."
ctaLabel={WIZARD_UPDATE_SAVE_LABEL}
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
@ -143,24 +142,7 @@ export const WizardUpdateBodyStep = ({
)}
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
WIZARD_UPDATE_SAVE_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando atualização.' : null}
</div>
</form>
</WizardStepFormChrome>
</CampaignWizardShell>
)
}

View file

@ -10,10 +10,10 @@ import {
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
@ -128,10 +128,12 @@ export const WizardExpectedVotesStep = ({
municipalityLabel={municipalityName}
contentFocus="none"
>
<div
className="flex flex-col gap-6"
aria-busy={isPending}
data-pending={isPending ? '' : undefined}
<WizardStepFormChrome
onCtaClick={handleConfirm}
isPending={isPending}
pendingAnnouncement="Salvando votos estimados."
ctaLabel={WIZARD_VOTES_FINAL_CTA_LABEL}
ctaClassName="w-full"
>
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
@ -172,27 +174,7 @@ export const WizardExpectedVotesStep = ({
<AlertDescription className="text-sm">{saveError}</AlertDescription>
</Alert>
) : null}
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando votos estimados.' : null}
</div>
<Button
type="button"
className="min-h-11 w-full"
disabled={isPending}
onClick={handleConfirm}
>
{isPending ? (
<>
<Spinner className="size-4" aria-hidden />
Salvando…
</>
) : (
WIZARD_VOTES_FINAL_CTA_LABEL
)}
</Button>
</div>
</WizardStepFormChrome>
</CampaignWizardShell>
)
}

View file

@ -5,32 +5,15 @@ import { useActionState, useCallback, useState } from 'react'
import { createWizardDemandFormAction } from '@/app/(campaign)/campanha/(app)/acoes/formActions'
import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { WIZARD_DEMAND_PENDING_ARIA, wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
CAMPAIGN_DEMAND_ACTIVITY_LABEL,
CAMPAIGN_DEMAND_BODY_DESCRIPTION,
CAMPAIGN_DEMAND_BODY_LABEL,
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
CAMPAIGN_DEMAND_KIND_LABEL,
CAMPAIGN_DEMAND_SUBMIT_LABEL,
campaignDemandKindLabels,
campaignDemandKinds,
} from '@/lib/schemas/campaignDemand'
import { CAMPAIGN_DEMAND_SUBMIT_LABEL } from '@/lib/schemas/campaignDemand'
type WizardRegisterDemandStepProps = {
actionSlug: string
@ -54,7 +37,7 @@ export const WizardRegisterDemandStep = ({
}: WizardRegisterDemandStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(createWizardDemandFormAction, {})
const [activity, setActivity] = useState<{ id: number; label: string } | null>(null)
const [activity, setActivity] = useState<DemandActivityValue | null>(null)
const searchActivities = useCallback(
(query: string) => searchDemandActivityOptions(query, municipalityId),
@ -80,87 +63,26 @@ export const WizardRegisterDemandStep = ({
municipalityLabel={municipalityName}
contentFocus="none"
>
<form
<WizardStepFormChrome
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
isPending={isPending}
pendingAnnouncement={WIZARD_DEMAND_PENDING_ARIA}
ctaLabel={CAMPAIGN_DEMAND_SUBMIT_LABEL}
ctaClassName="min-w-[8rem]"
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<Field>
<FieldLabel htmlFor="wizard-demand-kind">{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
<NativeSelect
id="wizard-demand-kind"
name="kind"
defaultValue="material"
required
disabled={isPending}
className="min-h-11 w-full sm:w-56"
>
{campaignDemandKinds.map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{campaignDemandKindLabels[kind]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
<AsyncSearchCombobox
name="activityId"
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
value={activity}
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
search={searchActivities}
onChange={setActivity}
/>
<FieldDescription>{CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION}</FieldDescription>
{state.fieldErrors?.activity ? (
<FieldError>{state.fieldErrors.activity}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="wizard-demand-description">{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
<Textarea
id="wizard-demand-description"
name="description"
rows={5}
minLength={2}
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
required
disabled={isPending}
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
className="min-h-28"
/>
{state.fieldErrors?.description ? (
<FieldError>{state.fieldErrors.description}</FieldError>
) : null}
</Field>
<DemandFields
idPrefix="wizard-demand"
disabled={isPending}
state={state}
activity={activity}
onActivityChange={setActivity}
searchActivities={searchActivities}
/>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[8rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
CAMPAIGN_DEMAND_SUBMIT_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? WIZARD_DEMAND_PENDING_ARIA : null}
</div>
</form>
</WizardStepFormChrome>
</CampaignWizardShell>
)
}

View file

@ -0,0 +1,89 @@
'use client'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
type WizardStepFormChromeCommonProps = {
isPending: boolean
/** sr-only announcement read out while the step is saving. */
pendingAnnouncement: string
ctaLabel: string
children: ReactNode
leadingSubmit?: ReactNode
submitBarClassName?: string
ctaClassName?: string
}
/**
* Shared chrome of the wizard final steps (D11): pending attributes, submit
* bar with Spinner + "Salvando…" and the sr-only pending announcement. Two
* modes — `action` renders a `<form>` with a submit CTA (the three
* `useActionState` steps); `onCtaClick` renders a plain `<div>` with a
* button CTA (the votes step, which saves via `useTransition` + fetch).
*/
export const WizardStepFormChrome = (
props:
| ({
action: (formData: FormData) => void | Promise<void>
} & WizardStepFormChromeCommonProps)
| ({ onCtaClick: () => void } & WizardStepFormChromeCommonProps),
) => {
const {
isPending,
pendingAnnouncement,
ctaLabel,
children,
leadingSubmit,
submitBarClassName,
ctaClassName,
} = props
const submitButton = (
<>
{leadingSubmit}
<Button
type={'action' in props ? 'submit' : 'button'}
disabled={isPending}
onClick={'action' in props ? undefined : props.onCtaClick}
className={cn('min-h-11 min-w-[7rem]', ctaClassName)}
>
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
ctaLabel
)}
</Button>
</>
)
const pendingAttrs = {
'aria-busy': isPending || undefined,
'data-pending': isPending ? '' : undefined,
}
const content = (
<>
{children}
<div className={cn('flex items-center justify-end', submitBarClassName)}>{submitButton}</div>
<div aria-live="polite" className="sr-only">
{isPending ? pendingAnnouncement : null}
</div>
</>
)
return 'action' in props ? (
<form action={props.action} className="flex flex-col gap-6" {...pendingAttrs}>
{content}
</form>
) : (
<div className="flex flex-col gap-6" {...pendingAttrs}>
{content}
</div>
)
}