merge: origin/main (CHANGELOG header + B197/OPS35+ entries)
This commit is contained in:
commit
5817b3a74b
12 changed files with 845 additions and 303 deletions
File diff suppressed because one or more lines are too long
86
docs/plans/changelog-agents-truncado-ops35-ops33-impl.md
Normal file
86
docs/plans/changelog-agents-truncado-ops35-ops33-impl.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Impl: OPS38 — Changelog-AGENTS truncado em main (restaurar do histórico)
|
||||
|
||||
Status: aprovado (gate humano 2026-08-10; segunda passada 2026-08-11)
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #629
|
||||
Intenção: body da Issue (sem plano linkado — body é a spec)
|
||||
Appetite restante: ~0,25 dia eng (docs-only; exploração já consumida, execução é minutos)
|
||||
|
||||
## Segunda passada (2026-08-11) — re-truncamento parcial no squash do PR #646
|
||||
|
||||
O PR #646 (primeira restauração) foi squash-merged sobre um main que **avançou** com 4 merges em voo que tocavam o CHANGELOG — push (#634, `e75148f3`), OPS37 (#624, `e86331fe`), OPS35+ (#638, `02a33f1b`), B197 (#639, `419ba39e`). O GitHub squash resolveu o conflito do arquivo **reproduzindo o mesmo defeito da Issue**: o topo do arquivo ficou com as 8 entradas pós-base (B197, OPS35+, push, OPS37, D9, B195, OPS33, OPS35) **fora da ordem canônica de merge** e o **header do arquivo sumiu** (título + descrição + `---`).
|
||||
|
||||
- **Verificação pós-merge:** 128 entradas presentes (120 base + 8), todas byte-idênticas às fontes (md5), base intacta (diff de entradas OPS28→fim = vazio) — mas header: 0 e ordem do topo: Push, OPS37, OPS35+, B197, D9, B195, OPS33, OPS35 (errada; B197 é a mais nova).
|
||||
- **Correção desta passada:** reconstruir o arquivo = header da base boa (`0b520fdb`) + 8 entradas na **ordem canônica de merge** (mais nova primeiro: B197 → OPS35+ → Push → OPS37 → D9 → B195 → OPS33 → OPS35) + base intacta (OPS28 em diante). Verificação: `git diff 0b520fdb` = **+8 entradas, −0**; 128 entradas; md5 das 8 vs fontes OK; `format:check` OK; `gate:fast` OK.
|
||||
- **Aprendizado (reforça a regra da Issue):** squash-merge de branch que reescreve um arquivo sobre um main que avançou também clobbera — conferir o arquivo no main **depois** do merge, não só no PR.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `docs/CHANGELOG-AGENTS.md` volta ao main com TODAS as entradas (as ~113 clobberadas restauradas do histórico + as 4 escritas depois do truncamento re-anexadas), sem duplicar nem alterar texto (fonte: git); branches em voo que editam o arquivo coordenadas (OPS37 #624, OPS30 #615).
|
||||
- **O que NÃO negociar:** texto canônico vindo do git (nunca redigitar); uma entrada por entrega; sem reordenar/reorganizar o arquivo; sem editar Issues in-progress.
|
||||
- **O que reavaliar:** a letra da Issue ("re-anexar OPS35/OPS33/OPS34/OPS28") está **desatualizada/parcial** — OPS34 e OPS28 **nunca saíram** do arquivo (estão na base boa, byte-idênticas); as entradas realmente perdidas no processo são **OPS35, OPS33, B195 e D9** (escritas após o truncamento, presentes no arquivo truncado atual). A fonte única de verdade é o inventário git abaixo.
|
||||
|
||||
## Caracterização (inventário verificado no git)
|
||||
|
||||
- **Base boa (última íntegra):** `0b520fdb` (merge OPS28, PR #614) — **246 linhas, 120 entradas**. Byte-idêntica a `0047d263` (commit OPS28) e a `07479f17^` (pai do merge OPS35).
|
||||
- **Truncamento 1 (o evento):** merge `07479f17` (OPS35, PR #621) — a branch OPS35 (`1f31d1b6`, baseada em `b4278f16` que tinha as 246 linhas) sobrescreveu o arquivo com uma cópia de **10 linhas** (só OPS28, OPS34, OPS35) na resolução de conflito → 246 → 10.
|
||||
- **Truncamento 2:** merge `f460dac6` (OPS33, PR #623) — a branch OPS33 (`f0f5b73f`) tinha o arquivo **completo (248 linhas)**; a resolução do merge descartou o lado da branch e manteve a cópia truncada de main + entrada OPS33 → 12 linhas.
|
||||
- **Depois:** B195 (merge `a2d02227`, PR #622) → 14 linhas; D9 (merge `48707d0b`, PR #630, atual main) → 18 linhas.
|
||||
- **Estado atual de main (18 linhas, 7 entradas):** B195, OPS33, D9, B193, OPS28, OPS34, OPS35.
|
||||
- **B193/OPS28/OPS34** já estão na base boa **byte-idênticos** (md5 verificado) → NÃO duplicar.
|
||||
- **B195/OPS33/D9/OPS35** são as 4 entradas pós-truncamento a re-anexar (md5 verificados byte-idênticos às fontes de branch: OPS35 = `1f31d1b6`, OPS33 = `f0f5b73f`).
|
||||
- **Ordem canônica do arquivo:** mais nova no topo (verificado: base boa começa com OPS28 #614, depois OPS34 #613 — ordem de merges). Ordem de merge das 4 re-anexadas (mais nova primeiro): **D9 (#630) → B195 (#622) → OPS33 (#623) → OPS35 (#621)**, acima da base boa (que começa em OPS28 #614).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Base boa 0b520fdb: 246 linhas, 120 entradas] --> D[+4 entradas re-anexadas no topo<br/>D9, B195, OPS33, OPS35]
|
||||
B[4 entradas extraídas verbatim<br/>do main atual (md5 vs fontes OK)] --> D
|
||||
D --> E[diff vs 0b520fdb = só as 4 linhas novas]
|
||||
E --> F[grep sanidade + format:check + gate:fast]
|
||||
F --> G[commit + push + PR Closes #629]
|
||||
G --> H[Nota de coordenação no PR:<br/>OPS37 #624 e OPS30 #615 re-anexam<br/>sua entrada no topo após rebase]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — restauração verbatim da base boa `0b520fdb` + re-anexação das 4 entradas pós-truncamento no topo, na ordem de merge (D9, B195, OPS33, OPS35). Única opção que devolve o arquivo íntegro sem reordenar nem editar texto.
|
||||
**Rejeitadas:**
|
||||
|
||||
- B — executar a letra da Issue ("re-anexar OPS35/OPS33/OPS34/OPS28"): OPS34/OPS28 já estão na base boa; re-anexá-los criaria duplicatas. A Issue foi escrita observando o arquivo truncado, não o inventário de merges.
|
||||
- C — reconstruir por concatenação de todos os commits de changelog: reescrita completa com risco de drift e sem necessidade (a base boa `0b520fdb` já contém 120 entradas íntegras).
|
||||
- D — adicionar guarda automatizada de presença de entradas no CHANGELOG: precedente D8 rejeitou ("processo = revisar diff em merges que o toquem"); ver Rabbit holes.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`docs/CHANGELOG-AGENTS.md`** (único arquivo): `git show 0b520fdb:docs/CHANGELOG-AGENTS.md` → novo conteúdo base; extrair as 4 entradas do main atual com `grep -oP` e colar verbatim no topo (acima da OPS28), separadas por linha em branco, na ordem D9 → B195 → OPS33 → OPS35. Diff final vs `0b520fdb` = exatamente +4 linhas de entrada (+4 blanks).
|
||||
- **`docs/plans/changelog-agents-truncado-ops35-ops33-impl.md`** (este plano, incluído no commit — padrão D8).
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** não se aplica.
|
||||
- **UI:** não se aplica (docs-only; CI `build-affected` pula o build).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fonte + colagem** — `git show 0b520fdb:docs/CHANGELOG-AGENTS.md > docs/CHANGELOG-AGENTS.md`; extrair as 4 entradas do arquivo atual com `grep` (linhas completas) e prependê-las verbatim.
|
||||
2. **Verificação de integridade** — `git diff 0b520fdb -- docs/CHANGELOG-AGENTS.md` mostra **só** as 4 entradas + blanks; `grep -c '^\*\*Recently resolved'` = 124 (120 + 4); cada uma das 7 entradas do arquivo atual presente exatamente 1× (B193/OPS28/OPS34 via base boa, sem duplicata); nenhuma entrada da base boa ausente (diff não mostra remoções).
|
||||
3. **Sanidade** — `pnpm format:check` (Prettier é a autoridade; `pnpm format` se necessário); `pnpm gate:fast`.
|
||||
4. **Entrega** — commit + push; PR `--base main` com `Closes #629` + auto-merge; `gh pr checks --watch --required`. Nota de coordenação no PR (ver Riscos).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Não criar guarda automatizada de presença de entradas** no CHANGELOG (precedente D8, `restaurar-entradas-changelog-b183-c102-impl.md`: "processo = revisar diff em merges que o toquem"). O processo recomendado (e repetido nesta entrega): revisar o diff do CHANGELOG em todo rebase/merge de branch que o toque — foi exatamente o que não aconteceu nos merges #621/#623.
|
||||
- Não reordenar a seção nem reorganizar o arquivo.
|
||||
- Não tocar branches de OPS37 (#624) e OPS30 (#615) — só coordenar via nota no PR.
|
||||
- Não editar outras Issues in-progress; não criar Issue nova.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Conflito com branches em voo (OPS37 #624, OPS30 #615)** — ambas editam o CHANGELOG sobre a versão truncada (OPS30 `8a459a11`: baseada em main pré-OPS28, 246 linhas + entrada OPS30; OPS37 `7c17d437`: baseada em main truncado pós-B195). Mitigação: nota no PR mandando rebasear e **re-anexar a própria entrada no topo do arquivo restaurado** (nunca resolver mantendo o lado da branch). Se um dos merges cair antes do meu, rebasear e re-aplicar a colagem (a colagem é idempotente por conteúdo).
|
||||
- **Drift de transcrição na colagem** → colagem via `git show`/`grep` (nunca redigitar) + verificação por md5 e diff (fase 2).
|
||||
- **gate:fast lento (docs-only muda nada)** → rodar mesmo assim (exigência da skill); esperado green em lint/typecheck/unit.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite da Issue ainda coberto (arquivo íntegro restaurado; entradas pós-truncamento re-anexadas; texto canônico intacto)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (docs-only; sem migration/access/Consent/DB)
|
||||
- [ ] Testes de domínio previstos: nenhum (docs); verificação por diff + grep + format:check + gate:fast
|
||||
36
docs/plans/e2e-combobox-label-strict-mode-flake.md
Normal file
36
docs/plans/e2e-combobox-label-strict-mode-flake.md
Normal 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)
|
||||
61
docs/plans/escala-dry-pos-b197.md
Normal file
61
docs/plans/escala-dry-pos-b197.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Escala e DRY pós-B197 (picker de colunas de assessores)
|
||||
|
||||
Status: pendente (capture-review-debts pós-B197, gate humano confirmado)
|
||||
Atualizado em: 2026-08-11
|
||||
Item do roadmap: fill-in **B197+** pós-[#627](https://github.com/fsolla/teqo/issues/627)
|
||||
Impeccable: A — sem superfície UI nova (DRY/estrutura)
|
||||
Appetite: ~0,5 dia eng
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O B197 entregou o seletor de colunas na lista de assessores — a primeira lista do
|
||||
mecanismo B17 que **não** usa `CampaignTable`. A tabela (`AdvisorsTable.tsx`) é
|
||||
client-side com `<th>` escritos à mão, então o menu do picker
|
||||
(`advisorPickerColumns` em `assessores/page.tsx`) é um array estático que
|
||||
**espelha** os cabeçalhos da tabela: `Nome/E-mail/Celular/Municípios/Ações`.
|
||||
O espelho é coberto por comentário, mas nada impede um item futuro de adicionar
|
||||
uma coluna na tabela e esquecer o menu — a coluna nasceria fora do picker (nem
|
||||
ocultável, nem listada), silenciosamente.
|
||||
|
||||
**Já resolvido no simplify/critique (não reabrir):** nada — o achado foi
|
||||
registrado direto do gate pós-entrega (nenhum outro follow-up do B197 ficou).
|
||||
|
||||
**Explicitamente fora:** conversão do `AdvisorsTable` para `CampaignTable`
|
||||
(rejeitada no impl do B197 — redesenho de superfície fora do pedido; o shell
|
||||
não modela a linha-draft de criação nem a edição inline por célula).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- O menu do picker de assessores nasce da **mesma fonte** que os `<th>` da
|
||||
tabela — adicionar coluna num lugar atualiza o outro de graça.
|
||||
- Sem mudança de comportamento: mesmos ids (`name` mandatory, `email`, `phone`,
|
||||
`municipalities`, `actions`), mesmos labels, exceção do draft intacta.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Opção A — fonte única serializável no módulo da tabela (recomendada).**
|
||||
`AdvisorsTable.tsx` (client) passa a declarar as colunas como dados
|
||||
(id/label/mandatory) e exporta uma versão serializável (`toCampaignColumnPickerColumns`
|
||||
já existe no lib B17); o `<th>` renderiza de `label`, e `assessores/page.tsx`
|
||||
importa a mesma fonte para o picker. Cliente-para-servidor vale para
|
||||
constantes serializáveis (mesmo padrão de `supporterPickerColumns` em
|
||||
`SupporterList.tsx`).
|
||||
- **Opção B — status quo (comentário).** Rejeitada: o comentário já existe e o
|
||||
achado é exatamente que ele é insuficiente para um time de agentes paralelos.
|
||||
- **Gatilho de execução:** a qualquer momento como fill-in (~0,5 dia); se C112
|
||||
(telefones) tocar o `AdvisorsTable` antes, executar junto (mesma superfície).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. Extrair a fonte única no `AdvisorsTable` + export serializável; `<th>` por
|
||||
`label`.
|
||||
2. `assessores/page.tsx` consome a fonte; remover `advisorPickerColumns`.
|
||||
3. Gates: `tsc`, `lint`, unit do lib, e2e `campaignColumnPicker` (assessores),
|
||||
`format:check`.
|
||||
|
||||
## Rabbit holes / Não escopo
|
||||
|
||||
- Não portar para `CampaignTable` (decisão travada do B197).
|
||||
- Não mexer em labels/ordem/ids das colunas — contrato do cookie `campaign_columns`.
|
||||
- Não criar coluna nova nenhuma — só o DRY do menu.
|
||||
86
docs/plans/escala-dry-pos-wizard-steps-impl.md
Normal file
86
docs/plans/escala-dry-pos-wizard-steps-impl.md
Normal 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
|
||||
175
src/components/campaign/demand/DemandFields.tsx
Normal file
175
src/components/campaign/demand/DemandFields.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
89
src/components/campaign/shared/WizardStepFormChrome.tsx
Normal file
89
src/components/campaign/shared/WizardStepFormChrome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue