B32+: share the campaign JSON route wrapper, fetch helper and cell auto-save.

`campaignJsonMutationRoute` now builds every JSON POST route under /campanha —
`isSameOriginRequest` had been copied into five of them, so the CSRF policy was
failing open by omission; a convention guard sweeps all of `src/app` and names
its three exceptions. `postCampaignJson` is the only place that spells
`credentials: 'same-origin'`, and `useCampaignCellAutosave` is the debounce /
abort / revert / flush-on-close machine the quick-edit cells shipped three
times.

The shell keeps the `aria-live` region outside the Popover/Drawer, since closing
is what commits and it was unmounting the node that would announce the result;
`variant="sheet"` took a `footer` slot, which let the signal control drop the
overlay it only kept for one button.

Two bugs the extraction made visible: a revert issued while a different payload
was in flight was dropped silently, and `change()` never cleared a past failure,
which outranks everything the live region would otherwise say.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 08:05:45 -03:00
parent c60d07c5f8
commit 5f11960c3a
30 changed files with 1396 additions and 830 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B32+ entregue em código (2026-07-28):** escala e DRY pós-B32 (+564 / −802 linhas em 15 arquivos, mais quatro novos; sem migration e sem mudança de contrato de rede). Toda rota JSON de `/campanha` agora nasce de **`campaignJsonMutationRoute(config, handler)`** — origem verificada, corpo parseado e `catch` mapeado por construção, com o `bodySchema.parse` **dentro do mesmo `try`** para não trocar a mensagem de corpo inválido; `parseCampaignJsonRequestBody`/`campaignJsonMutationErrorResponse` deixaram de ser exportados e um **guard de convenção** em `codebaseConventions.unit.spec.ts` recusa um `POST` que não passe pelo wrapper. Isso existe porque a política de mesma origem estava copiada em cinco rotas: a que esquecesse a linha aceitaria POST cross-origin em silêncio. `engagement-level` mantém o 409 dentro do handler. No cliente, `postCampaignJson` (transporte) e **`useCampaignCellAutosave`** (`shared/`) absorvem os três controles de auto-save. Três achados da auditoria que valem mais que o refactor: `display`/`draft` **nunca divergiram** (eram setados juntos em todos os caminhos — o hook tem um valor só, e o `noteDraft` do Trend fica no chamador porque texto cru e nota normalizada são coisas diferentes); `reportFailure` **não** existia nos quatro controles como o plano afirmava (o de assessores não importava `sonner`); e `flush()` idempotente elimina na raiz o `inFlightRef` que o B24 tinha como contorno de dois caminhos para o mesmo save. O hook é dono do `open` — fechar É o commit —, e `valueRef` avança sincronamente para não reler o render que agendou a mudança (bug que B33+ e B34 já pagaram). Casca: a região `aria-live` mudou para **fora** do Popover/Drawer (prop `statusMessage`), já que o fechamento que dispara a gravação desmontava justamente o nó que anunciaria "Salvando…"; e `variant="sheet"` ganhou slot `footer`, que tirou "Registrar movimento" de baixo da dobra no controle de nível (E14) e permitiu ao Sinal (B26) largar o Popover/Drawer próprios — `formId` na casca acabou desnecessário, porque `<form id>` no chamador + `<button form={id}>` no footer é HTML padrão, e `campaignCellEditTriggerClassName` virou privada por falta de quem a redigitasse. Cobertura nova: `campaignCellAutosave.unit.spec.ts` (fake timers) e `campaignJsonMutationRoute.unit.spec.ts`; essa máquina só tinha rede e2e antes. Plano: `docs/plans/escala-dry-pos-b32.md`.
- **B29 entregue em código (2026-07-28):** sort + filtro no header de `/campanha/liderancas`. Contrato próprio em `leadershipListUrl.ts` / `leadershipListFilters.ts` (`q`, `status`, `sector`, `municipality`, `access`, `sort`, `dir`, `page`; default `-updatedAt` omitido). Wrappers `LeadershipSortableHead` / `LeadershipHeaderFilter` / `LeadershipFilters` consomem o chrome do **B21 ✓** e o hook **B33+ ✓** (substitui `CampaignSearchForm`). Busca e sort por nome via join `contact.name` (int verde; teto `limit: 200` eliminado). Colunas novas Setor + Última atualização (idade relativa visível). Facet de municípios cross-filtrado; empty via `CampaignTable empty`; mobile sem barra empilhada (só header). Sem migration/Consent/action. Gate: tsc/lint/format/knip(P3)/cycles/652 unit/448 int/build; Aikido 0 findings. Plano: [`ordenacao-filtros-lista-liderancas.md`](../../../docs/plans/ordenacao-filtros-lista-liderancas.md).
- **E14 entregue em código (2026-07-27):** níveis de envolvimento N0–N4 por município, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓ existia desde 2026-07-24 com access e testes, sem nenhum caller). Migration `20260727_161752_add_municipality_engagement_level`: `engagementLevel` (select `n0…n4`, indexado, **sem default**), `levelNote` (textarea 2000), `levelChangedAt` (date indexada, `readOnly`, escrita por `canSetCampaignSystemField`) e o valor `movimento` no enum de `allocationDecision.outcome` — mover de nível não é `aceita` nem `descarta`, e fingir que era teria envenenado a triagem do E11. **Sem backfill:** o plano pedia default N2; carimbar N2 em 435 municípios afirmaria uma decisão que ninguém tomou, então nulo renderiza "Sem nível" e o filtro por ele **é** a fila de triagem inicial. Mover é decisão de realocação → `canManageMunicipalityEngagementLevel` (`isCampaignUnrestricted`: coordinator/candidate), leitura no bloco staff normal (`canReadCampaignStaffField`); o assessor lê e propõe por sinal. `setMunicipalityEngagementLevelRecord` segura `municipality-engagement-level:{id}` **antes** de ler o nível atual — sem o lock o `from` do snapshot é chute sob concorrência — e grava os dois documentos na mesma transação, com `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }`: só os valores sobre os quais a decisão foi tomada, nunca o documento. Histerese do relatório §6.8 em `src/lib/engagementLevel.ts` (puro, client-safe): salto de 2 níveis só com choque triangulado, 3 semanas de proteção pós-promoção (só contra rebaixamento) e **um movimento por mês**, derivável só de `levelChangedAt` — nenhuma query extra. **A rota tem três estados, não dois:** `blocked` (409) devolve as violações tipadas em vez de virar "não foi possível salvar", porque um movimento barrado é uma decisão que a coordenação pode tomar mesmo assim — o Popover mantém o rascunho e oferece o override, que entra no snapshot com exatamente as regras que contrariou. **Submit explícito, não auto-save** (exceção que `campanha-edit-where-you-see` prevê para flows com nota/confirmação; efeito colateral: o controle não vira o 4º call site da máquina de debounce que o **B32+** vai auditar). Lista: 11ª coluna staff com badge outline numerado, filtro `?level=` **nativo** (ao contrário de `classe` (E10), o campo é armazenado e entra no `where`; "sem nível" não cabe num `in` e vira ramo `or` com `{ exists: false }`) e sort `nivel` **em memória**: `n0…n4` até ordena lexicograficamente, mas o Postgres põe NULL primeiro no DESC e sem backfill "N4 primeiro" abriria com uma página de "Sem nível" — `sortByNullableValue` afunda o nulo nas duas direções, como `sem_base` em `classe`, ao custo de carregar o escopo inteiro (o que o sort padrão `deficit` já faz). Verbete `nivel-de-envolvimento` no E18. **Dois achados:** com `overrideAccess: false` o Payload **descarta o campo em silêncio** quando o ator não pode escrevê-lo, em vez de rejeitar o update — o int do assessor prova que o nível **não mudou**, não que a chamada lançou; e o banco de dev local estava fora do histórico de migrations (`allocation_decision.outcome` era `varchar`, sem o tipo `enum_allocation_decision_outcome` que o `teqo_test`, construído só por migrations, tinha) — reparado à mão no `teqo`, nada a fazer em produção. **Rebase sobre o B42:** o merge encaixou o bloco "Nível" dentro da gaveta mobile que o B42 acabara de criar, então o controle largou o Popover próprio e passou a receber `variant` como os três irmãos (`CampaignCellEditOverlay`: Popover no `md+`, Drawer abaixo) — o ramo Popover do container é ponto a ponto o que o E14 tinha à mão, e no toque o trigger passa a soletrar o nível. Suítes pós-rebase: 66 unit (630) e 54 int (436); Aikido 0 findings. Plano as-built: [`niveis-de-envolvimento.md`](../../../docs/plans/niveis-de-envolvimento.md).
- **E13 entregue em código (2026-07-27):** planejador de presença/giros. Núcleo decisório: `visitEligibility.ts` (puro, input explícito espelhando `TerritorialClassInput`) resolve as **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) e as 3 contraindicações (`perdida`/`sem_rede`/`no_teto`, sempre aviso com contra-oferta, nunca bloqueio); `visitPlannerAnchors.ts` guarda as datas das fases (`construcao|consolidacao|ativacao`, cortes ilustrativos no precedente de `territorialClassAnchors.ts`, comparação por data civil da Bahia via `formatBahiaCivilDate` novo em `campaignTime.ts`); `visitPlannerData.ts` (`server-only`) agrupa candidatas por TI. Superfícies: card "Elegibilidade para visita" no `OverviewTab` do detalhe do município e compositor em `/campanha/atividades/giros` (TI → âncora/satélites/semente → gerar rascunhos), alcançado por botão "Planejar giro" na página de Atividades, sem entrada nova no sidebar. **A auditoria pré-implementação derrubou duas premissas do plano de 24/07:** `kind: 'visita'` nunca entrou na migration do C12 e **não entra** — `activity.deputyPresent` já É o marcador de presença do candidato (o giro é derivável por `deputyPresent` + TI + janela de datas), e criar o enum custaria migration por um kind sobreposto a `reuniao_apoio`/`ato`; e `allocationDecision` não tem write path em `src/` (só o int spec), então o "não vá" fica textual e gravar o override virou gatilho do **E11**, que precisa desse write path de qualquer forma. Terceira correção: "satélites contíguos" era impossível — `bahiaTerritories.ts` só tem pertencimento, adjacência de polígono existe apenas nos scripts de build, então **encaixe = mesmo TI**, dito com essas palavras na UI (construir adjacência é o rabbit hole "otimizador de rota" que o plano já proibia). **Duas decisões de implementação que valem por si:** (1) o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do **E10** para `municipalityPotential.ts` (`uncapturedFieldVotes`, `catalogMedianUncapturedFieldVotes`, `catalogMedianProjectedValidVotes`, memoizadas) — sem isso o card "Elegibilidade" e a coluna "Classe" seriam duas contas do mesmo número na mesma tela, e ninguém saberia qual estava certa; (2) `createTourDraftActivitiesRecord` relê os ids das paradas com `overrideAccess: false` **dentro** da transação e aborta se algum não voltar, porque o `create` de `activity` é um booleano de staff e Payload não expressa restrição por município no create — buraco achado por int test, sem o qual um assessor semeava rascunhos no estado inteiro postando ids que o compositor nunca lhe ofereceu. Também: `loadMunicipalityVisitEligibility` roda o **mesmo** loader recortado no TI em vez de um segundo avaliador (encaixe é afirmação sobre vizinhos — não é avaliável para um município sozinho, e dois avaliadores começariam a discordar); `composeTourSuggestion` prioriza âncora → semente → satélites (a primeira versão deixava os satélites consumirem as vagas e a semente de expansão nunca aparecia); caps `TOUR_MAX_SATELLITES` 3 e `MAX_TOUR_STOPS` 8, verificados no cliente e revalidados na action; `TourRegionPicker` monta href com `usePathname` + `encodeURIComponent` em vez do serializador canônico (lição do **B14**: arrastaria `bahiaTerritories` + `municipalityCatalog` para o bundle). Dois verbetes E18 (`elegibilidade-para-visita`, `fase-do-calendario`). Suítes **fundidas, não acrescentadas** (arquivos separados empurraram unit e int para timeout): views do planejador em `tests/unit/visitEligibility.unit.spec.ts`, compositor em `tests/int/campaignActivity.int.spec.ts`; `CALENDAR_PHASES`/`VISIT_CONDITIONS`/`VISIT_CONTRAINDICATIONS` são exportadas e varridas lá para provar completude dos rótulos (um `const` usado só como tipo é warning de ESLint, e a varredura é a razão honesta de ser público). First Load JS: `/campanha/atividades/giros` 8,59 kB / 226 kB; detalhe do município 7,3 kB / 232 kB. Sem migration, sem collection, sem `Consent`, sem entidade `tour` persistida. Gate verde e bare (tsc, lint, format:check, knip com o P3 pré-existente, check:cycles, 429 unit+int, build local); Aikido **0 findings**. **e2e não serviu de veredito:** `campaignMunicipalities.e2e.spec.ts` falha em árvore stashada também, em teste diferente a cada rodada — o flake já ledgerizado. **Adiados com gatilho:** override "não vá" gravando `allocationDecision` → **E11**; níveis N0–N4 no gatilho do "não vá" → **E14** (até lá, classe E10 + rede vazia); padrões J como sugestão automática → E11 fase 2; entidade `tour` persistida → 3º giro real composto. Plano as-built: [`planejador-de-giros.md`](../../../docs/plans/planejador-de-giros.md).

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-B32 (editar Status de apoio na lista de lideranças)
Status: rascunho
Atualizado em: 2026-07-27
Status: **entregue em código (2026-07-28)** — ver "As-built" no fim
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B32+**, fill-in de engenharia)
Impeccable: A para F1/F2/F3 (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de rede nem markup visível); **B para F4/F5**, que mudam o que o usuário ouve e o que ele consegue tocar na casca compartilhada
Appetite: ~1,25–1,5 dia eng; F1 é uma auditoria com decisão binária (extrair ou registrar rejeição medida, mesmo formato do B33+); F2/F3 deixaram de ser condicionais quando o 5º call site chegou (E14 ✓); F4/F5 são fases da casca, independentes do resultado de F1
@ -102,6 +102,50 @@ Componentes (condicionais à decisão de cada fase):
- **`MunicipalityListAdvisorsControl` adotar o preâmbulo de fetch do F3** (se F3 for feito) — hoje ele tem uma política de erro diferente (`pendingCountRef`/sequência, sem revert único), então adotar herdaria só o `fetch`+`cast`, não a lógica de estado; barato, mas não é o call site que motivou o item. Gatilho: F3 concluído e aprovado.
- **Convergir o _corpo_ de multi-seleção (chips + `Command`), não só o container.** Contagem as-built, medida na triage do B42 ✓ (2026-07-27), para o próximo leitor não re-derivar: `shared/LeadershipStateDeputyRelationCell.tsx` (B36 ✓, já em `shared/` e já parametrizada por um record de `COPY`, servindo as duas direções da relação) e `MunicipalityListAdvisorsControl.tsx` (B27 ✓) repetem o mesmo corpo **string por string** — `w-80 p-0`, header `relative flex flex-col gap-2 p-3 pb-0`, Spinner, linha de chips `flex flex-wrap gap-1.5 px-3 pt-2`, o `Badge` de remover (`max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15`), `Command shouldFilter={false}` e a linha vazia `px-3 py-6 text-center`. `AdvisorMunicipalityCell` é uma terceira grafia, mas **inline** (sem overlay), então não conta como 3º call site do mesmo corpo. **Gatilho:** um 3º corpo em overlay (B34/B37 são os candidatos nomeados) — e aí a peça de destino é a que já mora em `shared/`, não uma quarta. Os planos B31/B34 registram este mesmo adiado do lado do widget; esta é a contagem do lado do markup.
## As-built (2026-07-28)
Saldo do lote: **+564 / −802 linhas** em 15 arquivos, mais quatro novos (`useCampaignCellAutosave.ts` 212, `campaignJsonRequest.ts` 25, e os dois specs). Nenhuma migration, collection, action, rota ou string visível nova.
**A auditoria mudou três coisas antes de qualquer código** (é o motivo de o item começar por ela):
1. **`display` vs `draft` não divergiam.** O plano tratava o par como a dificuldade central do F1. No código, `setDraft` e `setDisplay*` eram chamados juntos em **todos** os caminhos dos dois controles que os tinham — um valor com dois nomes. O hook tem **um** valor. O que é genuinamente separado é o `noteDraft` do Trend (texto cru vs. nota normalizada), e ele **ficou no chamador**, com um `useEffect` que só o re-sincroniza quando o valor normalizado do servidor diverge — preservar o que o usuário digitou, espaços inclusive, é o ponto.
2. **`reportFailure` não nascera nos quatro controles**, como o plano afirmava: `MunicipalityListAdvisorsControl` não importava `sonner`, então uma falha depois do fechamento era **muda** ali. Furo pré-existente do B42, não regressão; F4 o fechou.
3. **O helper do F2, como desenhado, não resolvia o motivo do F2.** `guardCampaignJsonMutationRequest(request, schema)` continuaria sendo uma linha que se pode esquecer — e a fase existe porque esquecê-la aceita POST cross-origin em silêncio. Virou **wrapper de rota**.
**F2 — `campaignJsonMutationRoute(config, handler)`.** O `POST` exportado por cada rota já sai com origem verificada, corpo parseado e `catch` mapeado; a rota fica só com schema, ação e payload de sucesso. O `bodySchema.parse` roda **dentro do mesmo `try`** do handler de propósito: é o que preserva a mensagem de hoje para corpo inválido (o erro do zod cai no `catch` e vira o `genericMessage` da rota) — mover o parse para fora teria trocado o texto sem ninguém pedir. `parseCampaignJsonRequestBody` e `campaignJsonMutationErrorResponse` **deixaram de ser exportados**: com um único consumidor, expô-los era manter viva a porta que a fase veio fechar. `engagement-level` mantém o seu 409 **dentro** do handler (o `EngagementLevelBlockedError` vira resposta antes de o wrapper ver erro nenhum), então o wrapper não cresceu um conceito de "blocked". O tipo do `bodySchema` é estrutural (`{ parse(input: unknown): T }`), não `ZodType`, para o utilitário não passar a depender do zod.
**O wrapper sozinho não impede a 6ª rota de nascer fora**, então veio com um guard de convenção em `codebaseConventions.unit.spec.ts` (mesmo molde do `campaign formActions convention`): todo `route.ts` sob `src/app/(campaign)` que exporte `POST` precisa chamar `campaignJsonMutationRoute(`. Verificado **vermelho** com um `route.ts` probe antes de ser aceito. Spec novo `campaignJsonMutationRoute.unit.spec.ts`: origem estrangeira → 403, corpo malformado → 400, violação de schema → 400 com a mensagem de hoje, `safeMessages` preservadas, sessão expirada → 401, e resposta de sucesso do handler devolvida intacta.
**F3 — `postCampaignJson`.** 25 linhas em `src/lib/campaignJsonRequest.ts`: `credentials: 'same-origin'`, headers, `cast` e `{ ok, payload }`. Nada de revert/retry — isso é do hook ou do chamador. Adotado pelos cinco controles.
**F1 — `useCampaignCellAutosave`.** Devolve `{ open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage }` e recebe `equals`/`endpoint`/`buildBody`/`readSaved`/`errorMessage`/`pendingMessage` (callbacks, nunca módulos de domínio — precedente `toHref` do B33+). Três decisões que valem mais que o número de linhas:
- **O hook é dono do `open`**, porque fechar **é** o commit: um chamador capaz de fechar sem dar flush é exatamente o bug que a extração remove.
- **`flush()` é o único caminho de save e é idempotente** — o `inFlightRef` do B24 (blur e fechamento disparando o mesmo payload) entrou no hook e agora protege os três, em vez de ser contorno de dois caminhos de código no chamador. Resolve a "Questão em aberto" pela opção (a).
- **`valueRef` avança sincronamente** em `change`, não no efeito: duas alterações no mesmo tick, ou um flush disparado pelo mesmo evento que mudou o valor, leriam o render que as agendou — o bug que B33+ e B34 já pagaram. As opções também viajam num `optionsRef`, para um save iniciado três renders atrás não reportar por um callback velho.
**Convergência aceita e documentada:** digitar de volta o valor original passou a limpar o `statusMessage` no Votos estimados (antes ficava preso em "Alterações serão salvas automaticamente."). É texto `sr-only`, e a alternativa era parametrizar um bug. Spec novo `campaignCellAutosave.unit.spec.ts` (jsdom + fake timers + `fetch` mockado): debounce, coalescência, cancelamento de save pendente, flush no fechamento, resposta superada ignorada, revert com toast **só** depois de fechado, adoção de valor externo. Essa máquina só tinha rede e2e antes — o spec é pré-requisito do refactor, não enfeite.
**F4 — a região viva sobrevive ao fechamento.** `CampaignCellEditOverlay` ganhou `statusMessage?: string` e renderiza o `<p className="sr-only" aria-live="polite">` **fora** do Popover/Drawer, ao lado do trigger, que permanece montado. Os cinco controles pararam de renderizar a própria região (uma por controle, sem região global — guardrail do B33+), e o Advisors ganhou o `toast.error` pós-fechamento do achado 2.
**F5 — slot `footer`, e o `formId` que a casca não precisou ter.** `footer?: ReactNode` substitui o "Fechar" default no `variant="sheet"`. `MunicipalityListLevelControl` passou "Registrar movimento" + "Cancelar" para o footer (antes o submit ficava no fim de um corpo rolável, com a ação que descarta pinada e a que constrói abaixo da dobra). **A migração condicional do Sinal foi feita e passou:** o plano previa um prop `formId` na casca; medindo, ele é desnecessário — o `id` vive no `<form>` do chamador e o `<button form={id}>` no footer é HTML padrão, com a action do React rodando porque o evento de submit acontece no form de qualquer jeito. Com isso o `MunicipalityListSignalControl` deixou de ter Popover/Drawer próprios (o único motivo de tê-los era o footer) e `campaignCellEditTriggerClassName` deixou de ser exportada: sem ninguém redigitando o trigger, virou privada do módulo. Os casos do spec da casca foram parametrizados pelo rótulo esperado no footer, com o Sinal como 5º caso, e há um pin de que a folha com `footer` **não** renderiza o "Fechar" default. **Detalhe de jsdom para o próximo:** `form={id}` submete lá, mas a validação nativa de `required` também roda — o caso do Sinal precisa preencher tipo e fonte antes de clicar, ou o clique não vira submit e o teste mente por um motivo que não é o do teste.
**Gate:** `tsc --noEmit`, `lint --max-warnings=0`, `format:check`, `knip` (só o erro pré-existente de `payload.config.ts`, P3), `check:cycles`, **661 unit / 444 int**, `build` local e Aikido sem achados. **E2e sob contenção pesada** — duas outras worktrees rodando `next dev` no mesmo host —, o que produziu falhas de compilação a frio e não de código: o prewarm do `setup` estourou 90 s, vários `page.goto`/login estouraram 30 s, e o Sinal falhou num `expect` de 5 s enquanto o snapshot de erro mostrava o botão já `disabled` com "Registrando sinal…" (ou seja, o submit **tinha** funcionado e a server action ainda compilava). Reexecutados isoladamente com o cache quente, `coordinator sets trend status and justification with auto-save (B24)` e `coordinator registers a typed signal from the list freshness cell` passam — este último é a verificação em navegador real do `<button form={id}>` do F5. Mesmo padrão de ambiente já fichado em B27 ✓/B32 ✓/B42 ✓.
**Fora do que mudou:** `MunicipalityListAdvisorsControl` continua com a sua máquina de delta (participou só de F2/F3/F4), e o corpo de multi-seleção segue adiado com o gatilho "3º corpo em overlay".
### `/simplify` — o que a revisão de reuso encontrou depois do gate
- **O guard de convenção varria só `src/app/(campaign)`**, ou seja, deixava aberto justamente o caso que ele existe para fechar: uma rota JSON autenticada por cookie escrita em `src/app/api/…` seria invisível à varredura e o esquecimento do `isSameOriginRequest` falha **aberto**. Agora varre `src/app` inteiro, com as três exceções declaradas com o motivo ao lado (dois re-exports dos handlers do próprio Payload; o `revalidate`, que é server-to-server autenticado por header secreto e cross-origin de propósito). O positivo também apertou de "menciona o wrapper em algum lugar" para `export const POST = campaignJsonMutationRoute(` — antes um arquivo podia parear um handler embrulhado com um segundo escrito à mão e passar.
- **`'Autenticação necessária.'` era literal em cinco lugares**, um deles ao lado do `CAMPAIGN_SESSION_EXPIRED_MESSAGE` importado. Aqui isso pesa mais que o normal porque `safeMessages` casa por **string exata**: um `throw` reescrito derrubaria silenciosamente um 401 na mensagem genérica. Virou `CAMPAIGN_AUTH_REQUIRED_MESSAGE` em `campaignFormActionError.ts`.
- **A região viva estava escrita em dois lugares fora da casca.** `LeadershipStateDeputyRelationCell` (B31) ainda a renderizava **dentro** do `PopoverContent` — o bug exato que o F4 corrigiu nos outros quatro —, então nada era anunciado depois que o popover fechava, que é quando o commit acontece; foi içada para fora do `<Popover>` (a migração completa para a casca continua sendo o gatilho de extração do B31). E `MunicipalityPortfolioCell` mantinha a sua, correta mas duplicando o que a casca passou a possuir: adotou `statusMessage`. A casca ganhou o `role="status"` que a portfolio já tinha — o `aria-atomic` implícito faz a frase ser anunciada inteira, que é o que essas mensagens são.
- **`reportFailure` estava escrito três vezes, comentário incluído** (hook + assessores + nível), sempre com o `openRef` que só existe para ele. Três call sites e uma política com nome — _fechar desmonta o Alert, então a falha precisa trocar de canal_ — viraram `useCampaignCellFailureChannel`. Deliberadamente **só** o canal de falha: a composição do `statusMessage` fica em cada controle, porque assessores e nível não têm estado sujo e os seus ternários são genuinamente diferentes.
- **Dois P1 que só a extração tornou visíveis, ambos verificados vermelhos contra o código anterior.** (a) **Um desfazer feito com outra requisição no ar era descartado em silêncio.** "Nada a fazer" comparava com `committedRef` — a última confirmação —, então com `B` no ar voltar para `A` parecia assentado: nada era enviado, e o sucesso de `B` repintava a célula como `B`. Assentado agora significa igual ao que o **servidor vai terminar com** (`isSettled`: o payload em voo quando há um, a última confirmação quando não há). Realista com os 600 ms da justificativa numa conexão ruim. (b) **`change()` não limpava o `errorMessage`**, que fica acima de `isPending`/`isDirty` no `statusMessage` — depois de uma falha, tudo o que o usuário digitasse na janela seguinte era anunciado como o erro velho, com o Alert destrutivo em pé sob o campo que ele estava consertando. O `MunicipalityListAdvisorsControl` já limpava; a extração perdeu isso.
- **`change` passou a aceitar um updater** (`change((current) => …)`), e a tendência usa: `value.status` no handler da justificativa é o render que agendou a tecla, então um select batched no mesmo tick era sobrescrito pelo antecessor — exatamente a classe de bug que o `valueRef` existe para fechar, e que de fora do hook não dava para consertar.
- **A região viva agora monta a partir da primeira abertura**, e essa é a única divergência real entre os dois revisores. Sempre montada, ela custava ~250 regions polite registradas numa página de 25 municípios (5 controles × as duas árvores irmãs, card e tabela) — carga sobre leitor de tela que nada aqui precisa. Montar a partir do primeiro `open` chega cedo o bastante para o "Salvando…" (uma live region precisa existir **antes** do texto mudar) e ela nunca desmonta depois, que é o caso do F4. **Consequência:** a adoção do `statusMessage` pelo `MunicipalityPortfolioCell` foi desfeita — no ponteiro fino o editor dele **é** a célula e o Drawer nunca abre, então a region ficaria muda; ele mantém a sua, sempre montada, com o motivo escrito ao lado.
- **`engagement-level` fazia o que o próprio comentário proíbe:** o comentário diz "ecoa o documento, nunca a requisição", e a linha seguinte era `updated.engagementLevel ?? movement.level` — num município nascido sem nível, uma escrita derrubada ecoaria o nível pedido como salvo. Um movimento sempre define um nível, então documento sem nível **é** a escrita derrubada: agora falha (com o `genericMessage` da rota) em vez de mentir.
- **Miudezas do mesmo passe:** `CampaignJsonErrorBody` era exportado sem consumidor externo (knip trata export morto como ERROR); o docblock de `sameOriginRequest` ainda descrevia a topologia antiga ("compartilhado por todo endpoint"), quando o compartilhamento agora é o wrapper; o pré-filtro do guard casava a palavra `POST` em qualquer lugar, o que faria de uma rota GET com "POST" num comentário um falso ofensor; e o `statusMessage` deixou de ser lido pelo `optionsRef` durante o render (o ref existe para o trabalho assíncrono).
- **Recusado com motivo:** reusar `useCampaignCellAutosave` nos três editores de conjunto (assessores, portfolio, dobradinhas) seria errado. Eles disparam uma requisição **por delta, sem debounce**, com várias em voo, e revertem só o delta que falhou — reconciliando por número de sequência, que o abort de geração única do hook não sabe expressar (ele **aborta** a requisição superada, o que para deltas independentes descarta um save). Generalizar exigiria parametrizar baseline, unidade de reversão, política de abort e transporte, e o hook deixaria de ser a máquina que ele documenta. `AdvisorDebouncedTextCell` é o quarto auto-save com debounce e o alvo mais forte que sobrou, mas ele salva por server action e não tem overlay: adotá-lo pediria transporte injetável e `open`/`onOpenChange` opcionais — fica recusado explicitamente aqui em vez de virar dívida silenciosa.
## Referências
- `docs/roadmap.md` (Fill-ins abertos — **B32+**)

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,7 @@ import {
type ActivityLeadershipOption,
} from '@/utilities/activityLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
const CONTACT_OPTION_LIMIT = 20
@ -17,7 +18,7 @@ export const searchActivityContactOptions = async (
query: string,
): Promise<ContactComboboxOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
const normalizedQuery = typeof query === 'string' ? query : ''
if (!isContactSearchQueryReady(normalizedQuery)) return []
@ -45,7 +46,7 @@ export const searchActivityLeadershipOptionsAction = async (
query: string,
): Promise<ActivityLeadershipOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
return searchActivityLeadershipOptions(payload, user, query)
}

View file

@ -5,11 +5,7 @@ import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)
import { updateLeadershipInternal } from '@/app/(campaign)/campanha/actions/leadership'
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { LeadershipListSupportStatusResponse } from './types'
@ -22,29 +18,19 @@ const bodySchema = z.object({
supportStatus: z.enum(leadershipSupportStatuses),
})
export async function POST(
request: Request,
): Promise<NextResponse<LeadershipListSupportStatusResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { leadershipId, supportStatus } = bodySchema.parse(parsed.body)
export const POST = campaignJsonMutationRoute(
{
bodySchema,
safeMessages: leadershipStaffEditSafeMessages,
genericMessage: 'Não foi possível salvar o status. Verifique seu acesso e tente novamente.',
},
async ({ leadershipId, supportStatus }) => {
const updated = await updateLeadershipInternal({ id: leadershipId, supportStatus })
return NextResponse.json({
return NextResponse.json<LeadershipListSupportStatusResponse>({
status: 'success',
message: 'Status de apoio atualizado.',
savedSupportStatus: updated.supportStatus,
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: leadershipStaffEditSafeMessages,
genericMessage: 'Não foi possível salvar o status. Verifique seu acesso e tente novamente.',
})
}
}
},
)

View file

@ -4,12 +4,8 @@ import { z } from 'zod'
import { setMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality'
import { MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES } from '@/lib/schemas/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import { uniqueRelationshipIds } from '@/utilities/relationship'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import type { MunicipalityListAdvisorsResponse } from './types'
@ -23,34 +19,24 @@ const bodySchema = z.object({
assigned: z.boolean(),
})
export async function POST(
request: Request,
): Promise<NextResponse<MunicipalityListAdvisorsResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, advisorId, assigned } = bodySchema.parse(parsed.body)
export const POST = campaignJsonMutationRoute(
{
bodySchema,
safeMessages: MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES,
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
},
async ({ municipalityId, advisorId, assigned }) => {
const updated = await setMunicipalityAdvisorMembership({
municipality: municipalityId,
advisor: advisorId,
assigned,
})
return NextResponse.json({
return NextResponse.json<MunicipalityListAdvisorsResponse>({
status: 'success',
message: assigned ? 'Assessor atribuído.' : 'Assessor removido.',
advisors: uniqueRelationshipIds(updated.advisors),
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES,
genericMessage:
'Não foi possível atualizar os assessores. Verifique seu acesso e tente novamente.',
})
}
}
},
)

View file

@ -7,11 +7,7 @@ import {
municipalityEngagementLevelSchema,
} from '@/lib/schemas/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { MunicipalityListEngagementLevelResponse } from './types'
@ -24,49 +20,51 @@ const bodySchema = municipalityEngagementLevelSchema
.omit({ municipality: true })
.extend({ municipalityId: positiveRelationshipId })
export async function POST(
request: Request,
): Promise<NextResponse<MunicipalityListEngagementLevelResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
export const POST = campaignJsonMutationRoute(
{
bodySchema,
// Only the unrestricted-actor message: this action reloads the actor with
// its own guard, never through `getFreshStaffActor` (same as `advisors/`).
safeMessages: [MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE],
genericMessage: 'Não foi possível registrar o nível. Verifique seu acesso e tente novamente.',
},
async ({ municipalityId, ...movement }) => {
// A blocked movement is the third state, not a failure: it is caught here
// so the shared error mapping never sees it and never has to know about it.
try {
const updated = await setMunicipalityEngagementLevel({
municipality: municipalityId,
...movement,
})
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, ...movement } = bodySchema.parse(parsed.body)
const updated = await setMunicipalityEngagementLevel({
municipality: municipalityId,
...movement,
})
return NextResponse.json({
status: 'success',
message: 'Nível de envolvimento registrado.',
// Echoes the document, never the request: with `overrideAccess: false`
// Payload drops a field the actor cannot write instead of refusing the
// update, so reporting the requested level could claim a movement that
// never landed.
savedLevel: {
level: updated.engagementLevel ?? movement.level,
note: updated.levelNote ?? null,
changedAt: updated.levelChangedAt ?? null,
},
})
} catch (error) {
if (error instanceof EngagementLevelBlockedError) {
return NextResponse.json(
{ status: 'blocked', message: error.message, violations: error.violations },
{ status: 409 },
)
}
// never landed. A movement always sets a level, so a document without one
// IS that dropped write — it fails rather than falling back to the
// request, which would be the lie this comment forbids.
// Not a safe message: the mapping answers with this route's
// `genericMessage`, and the internal text stays in the server log.
if (!updated.engagementLevel) throw new Error('Engagement level write was dropped.')
return campaignJsonMutationErrorResponse(error, {
// Only the unrestricted-actor message: this action reloads the actor with
// its own guard, never through `getFreshStaffActor` (same as `advisors/`).
safeMessages: [MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE],
genericMessage: 'Não foi possível registrar o nível. Verifique seu acesso e tente novamente.',
})
}
}
return NextResponse.json<MunicipalityListEngagementLevelResponse>({
status: 'success',
message: 'Nível de envolvimento registrado.',
savedLevel: {
level: updated.engagementLevel,
note: updated.levelNote ?? null,
changedAt: updated.levelChangedAt ?? null,
},
})
} catch (error) {
if (error instanceof EngagementLevelBlockedError) {
return NextResponse.json<MunicipalityListEngagementLevelResponse>(
{ status: 'blocked', message: error.message, violations: error.violations },
{ status: 409 },
)
}
throw error
}
},
)

View file

@ -5,11 +5,7 @@ import { municipalityStaffEditSafeMessages } from '@/app/(campaign)/campanha/(ap
import { setMunicipalityExpectedVotes } from '@/app/(campaign)/campanha/actions/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { MunicipalityListExpectedVotesResponse } from './types'
@ -29,33 +25,23 @@ const bodySchema = z.object({
}),
})
export async function POST(
request: Request,
): Promise<NextResponse<MunicipalityListExpectedVotesResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, expectedVotes } = bodySchema.parse(parsed.body)
export const POST = campaignJsonMutationRoute(
{
bodySchema,
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
},
async ({ municipalityId, expectedVotes }) => {
const updated = await setMunicipalityExpectedVotes({
municipality: municipalityId,
expectedVotes,
})
return NextResponse.json({
return NextResponse.json<MunicipalityListExpectedVotesResponse>({
status: 'success',
message: 'Votos estimados atualizados.',
savedExpectedVotes: toVoteEstimateScenarioViewModel(updated.expectedVotes),
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
})
}
}
},
)

View file

@ -4,11 +4,7 @@ import { municipalityStaffEditSafeMessages } from '@/app/(campaign)/campanha/(ap
import { setMunicipalityPoliticalTrend } from '@/app/(campaign)/campanha/actions/municipality'
import { municipalityPoliticalTrendSchema } from '@/lib/schemas/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { MunicipalityListPoliticalTrendResponse } from './types'
@ -21,25 +17,20 @@ const bodySchema = municipalityPoliticalTrendSchema
.omit({ municipality: true })
.extend({ municipalityId: positiveRelationshipId })
export async function POST(
request: Request,
): Promise<NextResponse<MunicipalityListPoliticalTrendResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { municipalityId, status, note } = bodySchema.parse(parsed.body)
export const POST = campaignJsonMutationRoute(
{
bodySchema,
safeMessages: municipalityStaffEditSafeMessages,
genericMessage: 'Não foi possível salvar a tendência. Verifique seu acesso e tente novamente.',
},
async ({ municipalityId, status, note }) => {
const updated = await setMunicipalityPoliticalTrend({
municipality: municipalityId,
status,
note,
})
return NextResponse.json({
return NextResponse.json<MunicipalityListPoliticalTrendResponse>({
status: 'success',
message: 'Tendência política registrada.',
savedTrend: {
@ -47,11 +38,5 @@ export async function POST(
note: updated.politicalTrend?.note ?? null,
},
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: municipalityStaffEditSafeMessages,
genericMessage:
'Não foi possível salvar a tendência. Verifique seu acesso e tente novamente.',
})
}
}
},
)

View file

@ -1,11 +1,9 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { LeadershipListSupportStatusResponse } from '@/app/(campaign)/campanha/(app)/liderancas/support-status/types'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
@ -31,134 +29,22 @@ export const LeadershipListSupportStatusControl = ({
leadershipID,
status,
}: LeadershipListSupportStatusControlProps) => {
const initialStatus = status ?? DEFAULT_STATUS
const [open, setOpen] = useState(false)
// Single value drives both the trigger badge and the select — unlike
// `MunicipalityListExpectedVotesControl`/`Trend`, there is only one field
// here, so a "display" value distinct from the "draft" would just be a
// second name for the same state.
const [value, setValue] = useState(initialStatus)
const [isDirty, setIsDirty] = useState(false)
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const abortRef = useRef<AbortController | null>(null)
const committedStatusRef = useRef(initialStatus)
const lastPropsStatusRef = useRef(initialStatus)
const saveGenerationRef = useRef(0)
const openRef = useRef(false)
/**
* Closing flushes the pending save and unmounts the Alert that would carry a
* failure, so a save that fails on the way out would revert the badge with
* nobody told. Same channel as the município controls: toast once closed,
* inline Alert while open.
*/
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
// Adopt server props only when they change from outside (navigation / RSC refresh).
useEffect(() => {
const next = status ?? DEFAULT_STATUS
if (next === lastPropsStatusRef.current) return
lastPropsStatusRef.current = next
committedStatusRef.current = next
setValue(next)
setIsDirty(false)
}, [status])
useEffect(
() => () => {
clearTimeout(saveTimeoutRef.current)
abortRef.current?.abort()
},
[],
)
const saveStatus = async (nextStatus: SupportStatus) => {
if (nextStatus === committedStatusRef.current) return
const generation = ++saveGenerationRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
try {
const response = await fetch(SUPPORT_STATUS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
body: JSON.stringify({ leadershipId: leadershipID, supportStatus: nextStatus }),
})
const payload = (await response.json()) as LeadershipListSupportStatusResponse
if (generation !== saveGenerationRef.current) return
if (!response.ok || payload.status !== 'success') {
setValue(committedStatusRef.current)
setIsDirty(false)
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
return
}
committedStatusRef.current = payload.savedSupportStatus
setValue(payload.savedSupportStatus)
setIsDirty(false)
} catch {
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
setValue(committedStatusRef.current)
setIsDirty(false)
reportFailure(SAVE_ERROR_MESSAGE)
} finally {
if (generation === saveGenerationRef.current) {
setIsPending(false)
}
}
}
const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<SupportStatus, LeadershipListSupportStatusResponse>({
value: status ?? DEFAULT_STATUS,
equals: (left, right) => left === right,
endpoint: SUPPORT_STATUS_ENDPOINT,
buildBody: (supportStatus) => ({ leadershipId: leadershipID, supportStatus }),
readSaved: (payload) => payload.savedSupportStatus,
errorMessage: SAVE_ERROR_MESSAGE,
pendingMessage: 'Salvando status de apoio.',
})
const handleStatusChange = (raw: string) => {
if (!isSupportStatus(raw)) return
setIsDirty(true)
setValue(raw)
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = undefined
void saveStatus(raw)
}, STATUS_AUTOSAVE_MS)
change(raw, STATUS_AUTOSAVE_MS)
}
const handleOpenChange = (nextOpen: boolean) => {
openRef.current = nextOpen
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
} else if (saveTimeoutRef.current !== undefined) {
// Flush a pending debounced save instead of letting the unmount
// cleanup drop it — closing right after picking an option must not
// silently discard the change (same precedent as `flushDraft` in
// `MunicipalityListTrendControl`).
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = undefined
void saveStatus(value)
}
setOpen(nextOpen)
}
const statusMessage = errorMessage
? errorMessage
: isPending
? 'Salvando status de apoio.'
: isDirty
? 'Alterações serão salvas automaticamente.'
: ''
return (
// The leaderships list is table-only, so this stays a Popover at every
// viewport — the shell is here for the trigger and content it already owns,
@ -166,10 +52,11 @@ export const LeadershipListSupportStatusControl = ({
<CampaignCellEditOverlay
variant="popover"
open={open}
onOpenChange={handleOpenChange}
onOpenChange={onOpenChange}
title="Editar status de apoio"
triggerLabel={`Editar status de apoio — ${supportStatusLabels[value]}`}
triggerBusy={isPending}
statusMessage={statusMessage}
contentClassName="w-64 p-3"
preventPopoverAutoFocus
trigger={<SupportStatusBadge status={value} />}
@ -203,9 +90,6 @@ export const LeadershipListSupportStatusControl = ({
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</CampaignCellEditOverlay>
)

View file

@ -13,10 +13,12 @@ import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import { matchesAtWordStart } from '@/lib/wordStartFilter'
@ -51,7 +53,8 @@ export const MunicipalityListAdvisorsControl = ({
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentAdvisorIDs)
const [query, setQuery] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
useCampaignCellFailureChannel()
const [isPending, setIsPending] = useState(false)
const pendingCountRef = useRef(0)
const lastPropsIDsRef = useRef(currentAdvisorIDs)
@ -67,6 +70,11 @@ export const MunicipalityListAdvisorsControl = ({
const requestSeqRef = useRef(0)
const latestConfirmedRef = useRef<{ seq: number; advisors: number[] } | null>(null)
const handleOpenChange = (nextOpen: boolean) => {
noteOpenChange(nextOpen)
setOpen(nextOpen)
}
// Adopt server props only when they change from outside (navigation / RSC
// refresh) — an in-flight delta's optimistic state must not be clobbered by
// a prop identical in content to what we already had.
@ -149,18 +157,14 @@ export const MunicipalityListAdvisorsControl = ({
}
try {
const response = await fetch(ADVISORS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ municipalityId: municipalityID, advisorId, assigned }),
})
const { ok, payload } = await postCampaignJson<MunicipalityListAdvisorsResponse>(
ADVISORS_ENDPOINT,
{ municipalityId: municipalityID, advisorId, assigned },
)
const payload = (await response.json()) as MunicipalityListAdvisorsResponse
if (!response.ok || payload.status !== 'success') {
if (!ok || payload.status !== 'success') {
revertDelta()
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
finishRequest()
return
}
@ -171,7 +175,7 @@ export const MunicipalityListAdvisorsControl = ({
finishRequest()
} catch {
revertDelta()
setErrorMessage(SAVE_ERROR_MESSAGE)
reportFailure(SAVE_ERROR_MESSAGE)
finishRequest()
}
})()
@ -187,13 +191,14 @@ export const MunicipalityListAdvisorsControl = ({
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
title="Atribuir assessores"
description={municipalityName}
// The avatar stack reads as initials at best, and an `aria-label` replaces
// even those — so who is assigned goes in the label, by extenso.
triggerLabel={`Editar assessores em ${municipalityName} — ${assignedLabel}`}
triggerBusy={isPending}
statusMessage={statusMessage}
tooltipContent={tooltipContent}
contentClassName="w-80 p-0"
sheetBodyClassName="px-0 pt-2"
@ -278,9 +283,6 @@ export const MunicipalityListAdvisorsControl = ({
</Alert>
</div>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</CampaignCellEditOverlay>
)
}

View file

@ -1,11 +1,9 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { MunicipalityListExpectedVotesResponse } from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
import { StaffMunicipalityVotesDisplay } from '@/components/campaign/votePledge/StaffMunicipalityVotesDisplay'
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
import { Alert, AlertDescription } from '@/components/ui/Alert'
@ -50,136 +48,21 @@ export const MunicipalityListExpectedVotesControl = ({
}: MunicipalityListExpectedVotesControlProps) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
const [open, setOpen] = useState(false)
const [displayVotes, setDisplayVotes] = useState(expectedVotes)
const [draft, setDraft] = useState(expectedVotes)
const [isDirty, setIsDirty] = useState(false)
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const abortRef = useRef<AbortController | null>(null)
const committedVotesRef = useRef(expectedVotes)
const lastPropsVotesRef = useRef(expectedVotes)
const saveGenerationRef = useRef(0)
const openRef = useRef(false)
const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<VoteEstimateScenarioViewModel, MunicipalityListExpectedVotesResponse>({
value: expectedVotes,
equals: voteEstimatesEqual,
endpoint: EXPECTED_VOTES_ENDPOINT,
buildBody: (values) => ({ municipalityId: municipalityID, expectedVotes: values }),
readSaved: (payload) => payload.savedExpectedVotes,
errorMessage: SAVE_ERROR_MESSAGE,
pendingMessage: 'Salvando votos estimados.',
})
/**
* Closing commits the draft and unmounts the Alert that would carry a
* failure, so a save that fails on the way out would silently revert the
* number. A toast outlives the overlay; while it is open the inline Alert is
* still the closer channel.
*/
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
// Adopt server props only when they change from outside (navigation / RSC refresh).
useEffect(() => {
if (voteEstimatesEqual(expectedVotes, lastPropsVotesRef.current)) return
lastPropsVotesRef.current = expectedVotes
committedVotesRef.current = expectedVotes
setDisplayVotes(expectedVotes)
setDraft(expectedVotes)
setIsDirty(false)
}, [expectedVotes])
useEffect(
() => () => {
clearTimeout(saveTimeoutRef.current)
abortRef.current?.abort()
},
[],
)
const saveDraft = async (values: VoteEstimateScenarioViewModel) => {
if (voteEstimatesEqual(values, committedVotesRef.current)) return
const generation = ++saveGenerationRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
try {
const response = await fetch(EXPECTED_VOTES_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
body: JSON.stringify({ municipalityId: municipalityID, expectedVotes: values }),
})
const payload = (await response.json()) as MunicipalityListExpectedVotesResponse
if (generation !== saveGenerationRef.current) return
if (!response.ok || payload.status !== 'success') {
setDisplayVotes(committedVotesRef.current)
setDraft(committedVotesRef.current)
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
return
}
committedVotesRef.current = payload.savedExpectedVotes
setDisplayVotes(payload.savedExpectedVotes)
setDraft(payload.savedExpectedVotes)
setIsDirty(false)
} catch {
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
setDisplayVotes(committedVotesRef.current)
setDraft(committedVotesRef.current)
reportFailure(SAVE_ERROR_MESSAGE)
} finally {
if (generation === saveGenerationRef.current) {
setIsPending(false)
}
}
}
const scheduleSave = (values: VoteEstimateScenarioViewModel) => {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
void saveDraft(values)
}, AUTOSAVE_MS)
}
const handleValuesChange = (values: VoteEstimateScenarioViewModel) => {
setIsDirty(true)
setDraft(values)
setDisplayVotes(values)
scheduleSave(values)
}
const handleOpenChange = (nextOpen: boolean) => {
openRef.current = nextOpen
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
setDraft(displayVotes)
} else if (open) {
clearTimeout(saveTimeoutRef.current)
if (!voteEstimatesEqual(draft, committedVotesRef.current)) {
void saveDraft(draft)
}
setIsDirty(false)
}
setOpen(nextOpen)
}
const statusMessage = errorMessage
? errorMessage
: isPending
? 'Salvando votos estimados.'
: isDirty
? 'Alterações serão salvas automaticamente.'
: ''
// An `aria-label` replaces the trigger's content, and the content here is the
// number itself — so the label has to carry it, and name the scenario it
// belongs to (the picker can disagree with the column's sort, E9).
const activeValue = displayVotes[activeScenario]
const activeValue = value[activeScenario]
const triggerLabel = [
'Editar votos estimados',
municipalityName ? ` em ${municipalityName}` : '',
@ -191,18 +74,19 @@ export const MunicipalityListExpectedVotesControl = ({
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={handleOpenChange}
onOpenChange={onOpenChange}
title="Editar votos estimados"
description={municipalityName}
triggerLabel={triggerLabel}
triggerBusy={isPending}
statusMessage={statusMessage}
triggerClassName="group flex w-full items-center justify-center"
align="center"
contentClassName="w-[15.5rem] p-3"
preventPopoverAutoFocus
trigger={
<StaffMunicipalityVotesDisplay
expectedVotes={displayVotes}
expectedVotes={value}
pledgeCoverage={pledgeCoverage}
activeScenario={activeScenario}
layout="compact"
@ -221,23 +105,20 @@ export const MunicipalityListExpectedVotesControl = ({
) : null}
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={draft}
values={value}
idPrefix={`municipality-list-expected-votes-${municipalityID}`}
variant="compact"
// Desktop: the popover is a transient overlay and typing is the
// point. On touch the Drawer would raise the virtual keyboard before
// the field is even read, so the first tap picks the scenario.
autoFocusScenario={variant === 'sheet' ? undefined : 'central'}
onValuesChange={handleValuesChange}
onValuesChange={(values) => change(values, AUTOSAVE_MS)}
/>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</CampaignCellEditOverlay>
)

View file

@ -1,7 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { MunicipalityListEngagementLevelResponse } from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
@ -9,13 +8,16 @@ import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { DrawerClose } from '@/components/ui/Drawer'
import { Field, FieldContent, 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 { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
ENGAGEMENT_LEVEL_RULES,
@ -75,7 +77,8 @@ export const MunicipalityListLevelControl = ({
const [triangulatedShock, setTriangulatedShock] = useState(false)
const [override, setOverride] = useState(false)
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
useCampaignCellFailureChannel()
// Violations only the server could know (it re-reads the município under the
// lock), kept with the level they were raised for so a changed draft drops them.
const [serverBlock, setServerBlock] = useState<{
@ -83,17 +86,8 @@ export const MunicipalityListLevelControl = ({
violations: EngagementLevelViolation[]
} | null>(null)
const abortRef = useRef<AbortController | null>(null)
const openRef = useRef(false)
const lastPropsRef = useRef({ level, levelNote, levelChangedAt })
// Closing unmounts the Alert and the live region, and nothing aborts a
// movement already in flight — so a failure after that would be silent.
// Same shape as the trend and estimate controls.
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
// Adopt the server value when it changes from outside (navigation / refresh).
useEffect(() => {
const last = lastPropsRef.current
@ -157,7 +151,7 @@ export const MunicipalityListLevelControl = ({
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) resetDraft()
openRef.current = nextOpen
noteOpenChange(nextOpen)
setOpen(nextOpen)
}
@ -171,25 +165,21 @@ export const MunicipalityListLevelControl = ({
setServerBlock(null)
try {
const response = await fetch(ENGAGEMENT_LEVEL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
body: JSON.stringify({
// Deliberately ignores the transport's `ok` the way the siblings do not:
// `blocked` arrives as a 409 carrying the violations the coordinator may
// override, so the status line is not the signal here — the discriminant is.
const { payload } = await postCampaignJson<MunicipalityListEngagementLevelResponse>(
ENGAGEMENT_LEVEL_ENDPOINT,
{
municipalityId: municipalityID,
level: draftLevel,
note,
reversalSignals,
triangulatedShock,
override,
}),
})
// Deliberately not gated on `response.ok` the way the siblings are:
// `blocked` arrives as a 409 carrying the violations the coordinator may
// override, so the status line is not the signal here — the discriminant is.
const payload = (await response.json()) as MunicipalityListEngagementLevelResponse
},
controller.signal,
)
if (payload.status === 'success') {
setSaved(payload.savedLevel)
@ -222,6 +212,18 @@ export const MunicipalityListLevelControl = ({
(violations.length === 0 || override)
const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}`
const isSheet = variant === 'sheet'
const submitButton = (
<Button
type="button"
size={isSheet ? 'default' : 'sm'}
className={isSheet ? 'min-h-11 w-full' : undefined}
disabled={!canSubmit || isPending}
onClick={() => void submit()}
>
{isPending ? <Spinner className="size-3.5" aria-hidden /> : null}
Registrar movimento
</Button>
)
return (
<CampaignCellEditOverlay
@ -232,6 +234,19 @@ export const MunicipalityListLevelControl = ({
description={municipalityName}
triggerLabel={`Nível de envolvimento de ${municipalityName}: ${currentLabel}`}
triggerBusy={isPending}
statusMessage={isPending ? 'Registrando nível.' : (errorMessage ?? '')}
footer={
isSheet ? (
<>
{submitButton}
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
</>
) : undefined
}
tooltipContent={
currentLevel ? (
<div className="space-y-1">
@ -339,19 +354,9 @@ export const MunicipalityListLevelControl = ({
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<Button
type="button"
size={isSheet ? 'default' : 'sm'}
className={isSheet ? 'min-h-11 w-full' : undefined}
disabled={!canSubmit || isPending}
onClick={() => void submit()}
>
{isPending ? <Spinner className="size-3.5" aria-hidden /> : null}
Registrar movimento
</Button>
<p className="sr-only" aria-live="polite">
{isPending ? 'Registrando nível.' : (errorMessage ?? '')}
</p>
{/* On touch the same button is pinned to the footer, out of a body
that scrolls past it. */}
{isSheet ? null : submitButton}
</div>
</CampaignCellEditOverlay>
)

View file

@ -5,23 +5,13 @@ import { toast } from 'sonner'
import { MunicipalitySignalFields } from '@/components/campaign/municipality/MunicipalitySignalFields'
import {
campaignCellEditTriggerClassName,
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { DrawerClose } from '@/components/ui/Drawer'
import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatMunicipalitySignalAgeLabel,
@ -43,41 +33,6 @@ type MunicipalityListSignalControlProps = {
children: ReactNode
}
const SignalFormFields = ({
municipalityID,
municipalitySlug,
idPrefix,
state,
isPending,
}: {
municipalityID: number
municipalitySlug: string
idPrefix: string
state: CampaignFormActionState
isPending: boolean
}) => (
<>
<input type="hidden" name="municipalityId" value={municipalityID} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<MunicipalitySignalFields idPrefix={idPrefix} fieldErrors={state.fieldErrors} />
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<span className="sr-only" aria-live="polite">
{isPending ? 'Registrando sinal…' : ''}
</span>
</>
)
const SubmitButton = ({ isPending }: { isPending: boolean }) => (
<Button type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Registrar sinal
</Button>
)
export const MunicipalityListSignalControl = ({
municipalityID,
municipalitySlug,
@ -92,8 +47,9 @@ export const MunicipalityListSignalControl = ({
const [state, submitAction, isPending] = useActionState(formAction, {})
const reactId = useId()
const idPrefix = `municipality-list-signal-${variant}-${municipalityID}-${reactId}`
const formId = `${idPrefix}-form`
const isSheet = variant === 'sheet'
const frescorLabel = formatMunicipalitySignalAgeLabel(municipalitySignalAgeInDays(lastSignalAt))
const triggerLabel = `Registrar sinal em ${municipalityName} — ${frescorLabel}`
useEffect(() => {
if (state.status !== 'success') return
@ -102,71 +58,61 @@ export const MunicipalityListSignalControl = ({
setFormKey((key) => key + 1)
}, [state.message, state.status])
// This control keeps its own container (its `<form>` has to wrap header, body
// and submit), so it borrows the shell's trigger instead of re-spelling it —
// `relative` included, which is what keeps the tap on the mobile card.
const triggerClassName = cn(campaignCellEditTriggerClassName, 'text-left')
const triggerProps = {
type: 'button',
'aria-expanded': open,
'aria-haspopup': 'dialog',
'aria-busy': isPending || undefined,
className: triggerClassName,
'aria-label': triggerLabel,
} as const
const fields = (
<SignalFormFields
municipalityID={municipalityID}
municipalitySlug={municipalitySlug}
idPrefix={idPrefix}
state={state}
isPending={isPending}
/>
const submitButton = (
// On the sheet this button lives in the footer, outside the `<form>` it
// submits: `form={formId}` is the standard association, and React's action
// still runs because the submit event fires on the form either way. That is
// what let this control drop the Drawer/Popover it used to hand-roll only
// because its `<form>` had to wrap the footer (B32+ F5).
<Button type="submit" form={formId} disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Registrar sinal
</Button>
)
if (variant === 'sheet') {
return (
<>
<button {...triggerProps} onClick={() => setOpen(true)}>
{children}
</button>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<form key={formKey} action={submitAction} className="flex min-h-0 flex-1 flex-col">
<DrawerHeader>
<DrawerTitle>Registrar sinal</DrawerTitle>
<DrawerDescription>{municipalityName}</DrawerDescription>
</DrawerHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 pb-2">
{fields}
</div>
<DrawerFooter>
<SubmitButton isPending={isPending} />
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
</>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button {...triggerProps}>{children}</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80">
<form key={formKey} action={submitAction} className="flex flex-col gap-3">
{fields}
<SubmitButton isPending={isPending} />
</form>
</PopoverContent>
</Popover>
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={setOpen}
title="Registrar sinal"
description={municipalityName}
triggerLabel={`Registrar sinal em ${municipalityName} — ${frescorLabel}`}
triggerBusy={isPending}
statusMessage={isPending ? 'Registrando sinal…' : ''}
triggerClassName="text-left"
contentClassName="w-80"
trigger={children}
footer={
isSheet ? (
<>
{submitButton}
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
</>
) : undefined
}
>
<form
key={formKey}
id={formId}
action={submitAction}
className="flex flex-col gap-3"
aria-busy={isPending || undefined}
>
<input type="hidden" name="municipalityId" value={municipalityID} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<MunicipalitySignalFields idPrefix={idPrefix} fieldErrors={state.fieldErrors} />
{state.message && state.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
{isSheet ? null : submitButton}
</form>
</CampaignCellEditOverlay>
)
}

View file

@ -1,7 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useEffect, useMemo, useState } from 'react'
import type {
MunicipalityListPoliticalTrendResponse,
@ -11,6 +10,7 @@ import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldLabel } from '@/components/ui/field'
@ -51,197 +51,46 @@ export const MunicipalityListTrendControl = ({
trendNote,
variant,
}: MunicipalityListTrendControlProps) => {
const initialTrend: MunicipalityListSavedPoliticalTrend = {
status,
note: normalizeNote(trendNote),
}
const [open, setOpen] = useState(false)
const [displayTrend, setDisplayTrend] = useState(initialTrend)
const [draft, setDraft] = useState(initialTrend)
const { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<
MunicipalityListSavedPoliticalTrend,
MunicipalityListPoliticalTrendResponse
>({
// Memoized because the hook's server-adoption effect keys on this
// reference; a fresh literal per render would run it (harmlessly, since
// `equals` guards the body) on every commit of the list.
value: useMemo(() => ({ status, note: normalizeNote(trendNote) }), [status, trendNote]),
equals: trendsEqual,
endpoint: POLITICAL_TREND_ENDPOINT,
buildBody: (trend) => ({
municipalityId: municipalityID,
status: trend.status,
note: trend.note,
}),
readSaved: (payload) => payload.savedTrend,
errorMessage: SAVE_ERROR_MESSAGE,
pendingMessage: 'Salvando tendência.',
})
// The only state the hook cannot hold: the textarea's raw text, which the
// saved note is the trimmed form of. It follows the saved note whenever that
// changes for a reason other than this typing (adoption, revert, server echo)
// and is left alone while the two still agree, so trailing spaces survive.
const [noteDraft, setNoteDraft] = useState(trendNote ?? '')
const [isDirty, setIsDirty] = useState(false)
const [isPending, setIsPending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const abortRef = useRef<AbortController | null>(null)
const committedTrendRef = useRef(initialTrend)
const lastPropsTrendRef = useRef(initialTrend)
const saveGenerationRef = useRef(0)
/** Skip abort+re-POST when blur and popover-close flush the same payload. */
const inFlightTrendRef = useRef<MunicipalityListSavedPoliticalTrend | null>(null)
const openRef = useRef(false)
/**
* Closing is what commits the draft, and closing also unmounts the Alert and
* the live region that would carry a failure — so a save that fails on the
* way out reverts the badge with nobody told. A toast outlives the overlay;
* while it is still open the inline Alert remains the closer channel.
*/
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
const adoptTrend = (trend: MunicipalityListSavedPoliticalTrend) => {
setDisplayTrend(trend)
setDraft(trend)
setNoteDraft(trend.note ?? '')
}
// Adopt server props only when they change from outside (navigation / RSC refresh).
useEffect(() => {
const next: MunicipalityListSavedPoliticalTrend = {
status,
note: normalizeNote(trendNote),
}
if (trendsEqual(next, lastPropsTrendRef.current)) return
lastPropsTrendRef.current = next
committedTrendRef.current = next
adoptTrend(next)
setIsDirty(false)
}, [status, trendNote])
setNoteDraft((current) =>
normalizeNote(current) === value.note ? current : (value.note ?? ''),
)
}, [value.note])
useEffect(
() => () => {
clearTimeout(saveTimeoutRef.current)
abortRef.current?.abort()
},
[],
)
const saveDraft = async (values: MunicipalityListSavedPoliticalTrend) => {
const normalized: MunicipalityListSavedPoliticalTrend = {
status: values.status,
note: normalizeNote(values.note),
}
if (trendsEqual(normalized, committedTrendRef.current)) return
if (inFlightTrendRef.current && trendsEqual(normalized, inFlightTrendRef.current)) return
const generation = ++saveGenerationRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
inFlightTrendRef.current = normalized
setIsPending(true)
setErrorMessage(null)
try {
const response = await fetch(POLITICAL_TREND_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
body: JSON.stringify({
municipalityId: municipalityID,
status: normalized.status,
note: normalized.note,
}),
})
const payload = (await response.json()) as MunicipalityListPoliticalTrendResponse
if (generation !== saveGenerationRef.current) return
if (!response.ok || payload.status !== 'success') {
adoptTrend(committedTrendRef.current)
setIsDirty(false)
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
return
}
committedTrendRef.current = payload.savedTrend
adoptTrend(payload.savedTrend)
setIsDirty(false)
} catch {
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
adoptTrend(committedTrendRef.current)
setIsDirty(false)
reportFailure(SAVE_ERROR_MESSAGE)
} finally {
if (generation === saveGenerationRef.current) {
inFlightTrendRef.current = null
setIsPending(false)
}
}
}
const scheduleSave = (values: MunicipalityListSavedPoliticalTrend, delayMs: number) => {
if (trendsEqual(values, committedTrendRef.current)) {
clearTimeout(saveTimeoutRef.current)
setIsDirty(false)
return
}
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
void saveDraft(values)
}, delayMs)
}
const handleStatusChange = (raw: string) => {
const next: MunicipalityListSavedPoliticalTrend = {
status: parsePoliticalTrendStatusFormValue(raw || undefined),
note: normalizeNote(noteDraft),
}
setIsDirty(true)
setDraft(next)
setDisplayTrend(next)
scheduleSave(next, STATUS_AUTOSAVE_MS)
}
const handleNoteChange = (raw: string) => {
const next: MunicipalityListSavedPoliticalTrend = {
status: draft.status,
note: normalizeNote(raw),
}
setNoteDraft(raw)
setIsDirty(true)
setDraft(next)
setDisplayTrend(next)
scheduleSave(next, NOTE_AUTOSAVE_MS)
}
const flushDraft = () => {
clearTimeout(saveTimeoutRef.current)
const next: MunicipalityListSavedPoliticalTrend = {
status: draft.status,
note: normalizeNote(noteDraft),
}
if (!trendsEqual(next, committedTrendRef.current)) {
void saveDraft(next)
}
setIsDirty(false)
}
const handleOpenChange = (nextOpen: boolean) => {
openRef.current = nextOpen
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
adoptTrend(displayTrend)
} else if (open) {
flushDraft()
}
setOpen(nextOpen)
}
const hasNote = Boolean(displayTrend.note)
const trendLabel = displayTrend.status
? politicalTrendLabels[displayTrend.status]
: 'Não registrada'
const statusMessage = errorMessage
? errorMessage
: isPending
? 'Salvando tendência.'
: isDirty
? 'Alterações serão salvas automaticamente.'
: ''
const hasNote = Boolean(value.note)
const trendLabel = value.status ? politicalTrendLabels[value.status] : 'Não registrada'
return (
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={handleOpenChange}
onOpenChange={onOpenChange}
title="Editar tendência"
description={municipalityName}
// The label names the current reading: an `aria-label` replaces the
@ -250,12 +99,13 @@ export const MunicipalityListTrendControl = ({
// `MunicipalityListSignalControl`'s.
triggerLabel={`Editar tendência política em ${municipalityName} — ${trendLabel}`}
triggerBusy={isPending}
tooltipContent={hasNote ? <p className="whitespace-pre-wrap">{displayTrend.note}</p> : null}
statusMessage={statusMessage}
tooltipContent={hasNote ? <p className="whitespace-pre-wrap">{value.note}</p> : null}
contentClassName="w-72 p-3"
preventPopoverAutoFocus
trigger={
displayTrend.status ? (
<Badge variant={politicalTrendBadgeVariant[displayTrend.status]}>{trendLabel}</Badge>
value.status ? (
<Badge variant={politicalTrendBadgeVariant[value.status]}>{trendLabel}</Badge>
) : (
<Badge variant="outline">{trendLabel}</Badge>
)
@ -272,8 +122,16 @@ export const MunicipalityListTrendControl = ({
<FieldLabel htmlFor={`municipality-list-trend-${municipalityID}`}>Tendência</FieldLabel>
<NativeSelect
id={`municipality-list-trend-${municipalityID}`}
value={draft.status ?? ''}
onChange={(event) => handleStatusChange(event.target.value)}
value={value.status ?? ''}
onChange={(event) =>
change(
(current) => ({
...current,
status: parsePoliticalTrendStatusFormValue(event.target.value || undefined),
}),
STATUS_AUTOSAVE_MS,
)
}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
@ -293,8 +151,17 @@ export const MunicipalityListTrendControl = ({
<Textarea
id={`municipality-list-trend-note-${municipalityID}`}
value={noteDraft}
onChange={(event) => handleNoteChange(event.target.value)}
onBlur={flushDraft}
onChange={(event) => {
setNoteDraft(event.target.value)
// Updater, not a literal: `value.status` here is the render that
// scheduled this keystroke, so a select change batched into the
// same tick would be overwritten by its predecessor.
change(
(current) => ({ ...current, note: normalizeNote(event.target.value) }),
NOTE_AUTOSAVE_MS,
)
}}
onBlur={flush}
maxLength={NOTE_MAX_LENGTH}
rows={3}
className="min-h-20 resize-y"
@ -306,9 +173,6 @@ export const MunicipalityListTrendControl = ({
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</CampaignCellEditOverlay>
)

View file

@ -1,6 +1,6 @@
'use client'
import { useRef, type ReactNode } from 'react'
import { useRef, useState, type ReactNode } from 'react'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Button } from '@/components/ui/button'
@ -18,12 +18,8 @@ import { cn } from '@/lib/utils'
export type CampaignCellEditOverlayVariant = 'popover' | 'sheet'
/**
* Shared so the triggers this container does NOT own — `MunicipalityListSignalControl`,
* which wraps its own `<form>` — can't drift from the ones it does.
* `relative` is load-bearing: see the comment where it is composed below.
*/
export const campaignCellEditTriggerClassName =
/** `relative` is load-bearing: see the comment where it is composed below. */
const campaignCellEditTriggerClassName =
'relative min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
type CampaignCellEditOverlayProps = {
@ -36,6 +32,14 @@ type CampaignCellEditOverlayProps = {
triggerLabel: string
triggerClassName?: string
triggerBusy?: boolean
/**
* Announced by the region this shell keeps OUTSIDE the overlay. Closing is
* what commits a draft, and a region rendered inside the Popover/Drawer
* unmounts with it — so the one announcement that matters most ("Salvando…",
* and the failure that follows) was the one that never reached a screen
* reader (B32+ F4).
*/
statusMessage?: string
children: ReactNode
// Popover only ─────────────────────────────────────────────────────────────
@ -54,6 +58,15 @@ type CampaignCellEditOverlayProps = {
/** Subject line under the title: which row is being edited. */
description?: string
sheetBodyClassName?: string
/**
* Replaces the default "Fechar" for a sheet whose primary action is an
* explicit submit (E14's "Registrar movimento"): in a scrolling body the
* button drifts below the fold, and the footer is where a thumb expects it.
* A submit that belongs to a `<form>` in `children` is associated by the
* standard `form` attribute — the caller owns both ids, so the shell does not
* need to know there is a form at all.
*/
footer?: ReactNode
}
/**
@ -80,13 +93,26 @@ export const CampaignCellEditOverlay = ({
triggerLabel,
triggerClassName,
triggerBusy,
statusMessage,
tooltipContent,
align = 'start',
contentClassName,
sheetBodyClassName,
footer,
preventPopoverAutoFocus,
children,
}: CampaignCellEditOverlayProps) => {
/**
* The region is mounted from the first open onwards and never unmounted
* again — a live region has to exist BEFORE its text changes or most screen
* readers skip the first message, and it has to outlive the close, which is
* what commits. Mounting it unconditionally instead would leave ~250 polite
* regions registered on a 25-row município page (5 controls × 2 sibling
* trees), which is a load on assistive tech that nothing here needs.
*/
const [everOpened, setEverOpened] = useState(open)
if (open && !everOpened) setEverOpened(true)
const titleRef = useRef<HTMLHeadingElement | null>(null)
const triggerButton = (
<button
@ -113,10 +139,24 @@ export const CampaignCellEditOverlay = ({
</button>
)
// One region per control, next to its trigger — never a single global one:
// two cells saving at once would then overwrite each other's announcement,
// and neither would say which row it came from.
// `role="status"` and not a bare `aria-live`: its implicit `aria-atomic`
// makes the sentence be announced whole, which is what these messages are —
// `MunicipalityPortfolioCell` had already settled on it.
const liveRegion =
statusMessage === undefined || !everOpened ? null : (
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}
</p>
)
if (variant === 'sheet') {
return (
<>
{triggerButton}
{liveRegion}
<Drawer open={open} onOpenChange={onOpenChange}>
{/*
* Focus lands on the title, never on the first field: base-ui's
@ -144,11 +184,15 @@ export const CampaignCellEditOverlay = ({
{children}
</div>
<DrawerFooter>
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Fechar
</DrawerClose>
{footer ?? (
// Swiping down is the gesture, but it is not an affordance
// anyone sees.
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Fechar
</DrawerClose>
)}
</DrawerFooter>
</DrawerContent>
</Drawer>
@ -157,26 +201,29 @@ export const CampaignCellEditOverlay = ({
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<CampaignHoverTooltip
content={tooltipContent}
align={align}
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent
align={align}
sideOffset={8}
className={contentClassName}
// Radix gives the popover `role="dialog"` and no name; the Drawer's
// title is the same sentence, so it serves as one here too.
aria-label={title}
onOpenAutoFocus={preventPopoverAutoFocus ? (event) => event.preventDefault() : undefined}
>
{children}
</PopoverContent>
</Popover>
<>
<Popover open={open} onOpenChange={onOpenChange}>
<CampaignHoverTooltip
content={tooltipContent}
align={align}
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent
align={align}
sideOffset={8}
className={contentClassName}
// Radix gives the popover `role="dialog"` and no name; the Drawer's
// title is the same sentence, so it serves as one here too.
aria-label={title}
onOpenAutoFocus={preventPopoverAutoFocus ? (event) => event.preventDefault() : undefined}
>
{children}
</PopoverContent>
</Popover>
{liveRegion}
</>
)
}

View file

@ -375,11 +375,18 @@ export const LeadershipStateDeputyRelationCell = ({
)}
</CommandList>
</Command>
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</PopoverContent>
</Popover>
{/*
* Outside the Popover on purpose (B32+ F4): a region that unmounts with
* the overlay announces nothing once the overlay closes, and closing is
* what commits here. This cell still hand-rolls its trigger and popover —
* migrating it onto `CampaignCellEditOverlay` is B31's extraction trigger.
*/}
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}
</p>
</div>
)
}

View file

@ -799,6 +799,12 @@ export const MunicipalityPortfolioCell = ({
</>
</CampaignCellEditOverlay>
{/*
* This cell keeps its own region rather than the shell's `statusMessage`:
* the shell mounts that one from the first open of the overlay, and on a
* fine pointer this cell's editor IS the cell — the Drawer never opens,
* so nothing would ever be announced.
*/}
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}
</p>

View file

@ -0,0 +1,233 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
const DIRTY_MESSAGE = 'Alterações serão salvas automaticamente.'
type CampaignCellAutosavePayload = { status: string; message: string }
type SuccessPayload<TResponse> = Extract<TResponse, { status: 'success' }>
type UseCampaignCellAutosaveOptions<TValue, TResponse extends CampaignCellAutosavePayload> = {
/** Server value. Adopted only when it changes from OUTSIDE (navigation / RSC refresh). */
value: TValue
/** What "unchanged" means for this shape — normalization included (see the trend's note). */
equals: (left: TValue, right: TValue) => boolean
endpoint: string
buildBody: (value: TValue) => unknown
readSaved: (payload: SuccessPayload<TResponse>) => TValue
/** Shown when the server fails without a safe message of its own. */
errorMessage: string
/** Announced while a save is in flight, e.g. "Salvando tendência." */
pendingMessage: string
}
/**
* The auto-save machine behind the quick-edit cells (B24 tendência, B27 votos
* estimados, B32 status de apoio): debounce, abort of a superseded request,
* revert to the last confirmed value, and the flush that closing the overlay
* performs.
*
* Four invariants it exists to hold:
*
* - **One value.** A field that normalizes (the trend's justificativa) keeps
* its raw draft string in the caller; everything else is this one value.
* - **`flush()` is the only save path**, so blur-then-close cannot send the
* same payload twice.
* - **Closing IS the commit** — hence the hook owning `open`. A caller that
* could close without flushing is the bug this replaces.
* - **`valueRef` advances synchronously**, so work scheduled by an event
* cannot read the render that scheduled it.
*
* `value` may be a fresh object per render (the trend's is): the adoption
* effect is guarded by `equals`, not by React's dependency comparison, which
* makes a correct `equals` load-bearing for a caller passing an unstable
* reference.
*/
export const useCampaignCellAutosave = <TValue, TResponse extends CampaignCellAutosavePayload>(
options: UseCampaignCellAutosaveOptions<TValue, TResponse>,
) => {
const { value: serverValue } = options
// Effects and async work read the latest options through a ref: every
// callback here is recreated per render, but a save started three renders ago
// must not report through a stale one. Written during render on purpose — a
// save started by a handler in this same commit has to see these options, and
// rows are keyed by id, so a discarded render carries the same props anyway.
const optionsRef = useRef(options)
optionsRef.current = options
const [open, setOpen] = useState(false)
const [value, setValue] = useState(serverValue)
const [isDirty, setIsDirty] = useState(false)
const [isPending, setIsPending] = useState(false)
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
useCampaignCellFailureChannel()
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const abortRef = useRef<AbortController | null>(null)
const committedRef = useRef(serverValue)
const lastPropsRef = useRef(serverValue)
const saveGenerationRef = useRef(0)
/** Payload of the request in flight: blur and close flush the same one. */
const inFlightRef = useRef<TValue | null>(null)
/**
* Advanced synchronously so two changes batched into one tick — or a flush
* fired by the same event that changed the value — cannot read the render
* that scheduled them (the bug B33+/B34 both paid for).
*/
const valueRef = useRef(serverValue)
const commitLocalValue = (next: TValue) => {
valueRef.current = next
setValue(next)
}
// Adopt server props only when they change from outside (navigation / RSC refresh).
useEffect(() => {
if (optionsRef.current.equals(serverValue, lastPropsRef.current)) return
lastPropsRef.current = serverValue
committedRef.current = serverValue
valueRef.current = serverValue
setValue(serverValue)
setIsDirty(false)
// `equals` is read off the ref: an inline arrow at the call site would
// otherwise re-run this on every render.
}, [serverValue])
// A row unmounted mid-debounce drops the pending edit. Deliberate: there
// would be nothing left to report a failure through.
useEffect(
() => () => {
clearTimeout(saveTimeoutRef.current)
abortRef.current?.abort()
},
[],
)
/**
* "Nothing left to do" means equal to what the server will END UP with, which
* during a request is the payload in flight and not the last confirmation.
* Comparing against `committedRef` alone silently dropped a revert: with `B`
* in flight, going back to `A` looked settled, so nothing was sent and `B`'s
* success then repainted the cell as `B`.
*/
const isSettled = (next: TValue) => {
const { equals } = optionsRef.current
return inFlightRef.current !== null
? equals(next, inFlightRef.current)
: equals(next, committedRef.current)
}
const save = async (next: TValue) => {
const current = optionsRef.current
if (isSettled(next)) return
const generation = ++saveGenerationRef.current
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
inFlightRef.current = next
setIsPending(true)
setErrorMessage(null)
const revert = () => {
commitLocalValue(committedRef.current)
setIsDirty(false)
}
try {
const { ok, payload } = await postCampaignJson<TResponse>(
current.endpoint,
current.buildBody(next),
controller.signal,
)
if (generation !== saveGenerationRef.current) return
if (!ok || payload.status !== 'success') {
revert()
reportFailure(payload.status === 'error' ? payload.message : current.errorMessage)
return
}
// Narrowed by the discriminant just above. The cast is the cheaper trade:
// typing `readSaved` over the whole union would push a `status` re-check
// into all three callers, which have already been told this succeeded.
committedRef.current = current.readSaved(payload as SuccessPayload<TResponse>)
commitLocalValue(committedRef.current)
setIsDirty(false)
} catch {
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
revert()
reportFailure(current.errorMessage)
} finally {
if (generation === saveGenerationRef.current) {
inFlightRef.current = null
setIsPending(false)
}
}
}
/**
* Applies the value locally and schedules the save; a value already settled
* cancels it. Takes an updater when the next value depends on the current
* one — two controls in the same tick (a select plus a keystroke) must not
* each build their payload from the render that scheduled them.
*/
const change = (next: TValue | ((current: TValue) => TValue), delayMs: number) => {
const resolved =
typeof next === 'function' ? (next as (current: TValue) => TValue)(valueRef.current) : next
commitLocalValue(resolved)
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = undefined
// Editing is what clears a past failure: leaving it set would keep it above
// `isPending`/`isDirty` in `statusMessage`, announcing the old error over
// everything typed next.
setErrorMessage(null)
if (isSettled(resolved)) {
setIsDirty(false)
return
}
setIsDirty(true)
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = undefined
void save(resolved)
}, delayMs)
}
/** Sends a pending change now instead of waiting out the debounce. Idempotent. */
const flush = () => {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = undefined
void save(valueRef.current)
setIsDirty(false)
}
const onOpenChange = (nextOpen: boolean) => {
noteOpenChange(nextOpen)
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
} else if (open) {
flush()
}
setOpen(nextOpen)
}
const statusMessage = errorMessage
? errorMessage
: isPending
? options.pendingMessage
: isDirty
? DIRTY_MESSAGE
: ''
return { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage }
}

View file

@ -0,0 +1,33 @@
'use client'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
/**
* One policy, worth naming because every quick-edit cell has to obey it:
* **closing unmounts the inline Alert, so a failure that arrives after that has
* to change channel.** These cells keep saving past the dismissal — closing is
* what commits the draft, and nothing aborts a request already in flight — so a
* revert reported only through the Alert is a revert reported to nobody.
*
* While the overlay is open the Alert is the closer channel; once closed, a
* toast is what outlives it.
*
* The caller advances `noteOpenChange` from its own `onOpenChange`: the ref, not
* the state, is what an async callback started three renders ago can read.
*/
export const useCampaignCellFailureChannel = () => {
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const openRef = useRef(false)
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
const noteOpenChange = (nextOpen: boolean) => {
openRef.current = nextOpen
}
return { errorMessage, setErrorMessage, reportFailure, noteOpenChange }
}

View file

@ -0,0 +1,25 @@
/**
* Transport half of the list cells' quick edits: the preamble every control
* repeated around `fetch` (same-origin credentials, JSON headers, the cast of
* the discriminated response). Deliberately knows nothing about revert, retry
* or debounce — that is `useCampaignCellAutosave`'s, or the caller's.
*
* `ok` travels next to the payload because a route can answer a real body with
* a non-200 status (`engagement-level` returns its `blocked` state as 409), so
* the two are not the same question.
*/
export const postCampaignJson = async <TResponse>(
url: string,
body: unknown,
signal?: AbortSignal,
): Promise<{ ok: boolean; payload: TResponse }> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
signal,
body: JSON.stringify(body),
})
return { ok: response.ok, payload: (await response.json()) as TResponse }
}

View file

@ -6,6 +6,7 @@ import { getPayload, type Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { isCampaignStaff, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
export type CampaignActionContext = {
@ -20,7 +21,7 @@ export const requireCampaignUser = async (
getUser: CampaignUserGetter = getCampaignUser,
): Promise<CampaignUser> => {
const actor = await getUser()
if (!actor) throw new Error('Autenticação necessária.')
if (!actor) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
return actor
}

View file

@ -15,6 +15,12 @@ export type CampaignFormActionState = CampaignFormErrorState<undefined> & {
}
export const CAMPAIGN_SESSION_EXPIRED_MESSAGE = 'Sessão expirada. Entre novamente.'
/**
* Thrown when there is no campaign session at all. It is matched by exact
* string in `safeMessages`, so a reworded throw would silently collapse a 401
* into the generic message — hence the constant rather than the literal.
*/
export const CAMPAIGN_AUTH_REQUIRED_MESSAGE = 'Autenticação necessária.'
export type MapCampaignFormActionErrorOptions<Values> = {
error: unknown

View file

@ -1,18 +1,29 @@
import { NextResponse } from 'next/server'
import {
CAMPAIGN_AUTH_REQUIRED_MESSAGE,
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export type CampaignJsonErrorBody = { status: 'error'; message: string }
type CampaignJsonErrorBody = { status: 'error'; message: string }
/**
* Shared shell for `POST /campanha/**` JSON mutation routes (list-popover
* auto-save): parses the body, mapping a malformed one to the same 400 both
* routes already used. Domain parsing/success payloads stay per-route.
* Structural on purpose: any zod schema satisfies it, and the shell stays out
* of the validation library's type surface.
*/
export const parseCampaignJsonRequestBody = async (
type CampaignJsonBodySchema<TBody> = { parse: (value: unknown) => TBody }
type CampaignJsonMutationRouteConfig<TBody> = {
bodySchema: CampaignJsonBodySchema<TBody>
/** Domain messages allowed to reach the client verbatim (everything else collapses). */
safeMessages: readonly string[]
genericMessage: string
}
/** Parses the body, mapping a malformed one to a 400 every route shares. */
const parseCampaignJsonRequestBody = async (
request: Request,
): Promise<
{ ok: true; body: unknown } | { ok: false; response: NextResponse<CampaignJsonErrorBody> }
@ -35,19 +46,23 @@ export const parseCampaignJsonRequestBody = async (
* two auth ones, appended once here) pass through as 400, an expired/missing
* session is 401, anything else collapses to `genericMessage`.
*/
export const campaignJsonMutationErrorResponse = (
const campaignJsonMutationErrorResponse = (
error: unknown,
{ safeMessages, genericMessage }: { safeMessages: readonly string[]; genericMessage: string },
): NextResponse<CampaignJsonErrorBody> => {
const mapped = mapCampaignFormActionError({
error,
safeMessages: [...safeMessages, 'Autenticação necessária.', CAMPAIGN_SESSION_EXPIRED_MESSAGE],
safeMessages: [
...safeMessages,
CAMPAIGN_AUTH_REQUIRED_MESSAGE,
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
],
genericMessage,
})
const isAuthError =
error instanceof Error &&
(error.message === 'Autenticação necessária.' ||
(error.message === CAMPAIGN_AUTH_REQUIRED_MESSAGE ||
error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE)
return NextResponse.json(
@ -55,3 +70,41 @@ export const campaignJsonMutationErrorResponse = (
{ status: isAuthError ? 401 : 400 },
)
}
/**
* Builds the `POST` of a `/campanha/**` JSON mutation route (the list cells'
* quick edits): same-origin check, body parse, schema parse and error mapping,
* leaving each route with its schema, its action call and its success payload.
*
* It is a wrapper and not a `guard(request)` helper because the origin check is
* the kind of line a sixth route forgets, and forgetting it fails OPEN — a
* cookie-authenticated POST from anywhere would be honoured. Here it is
* structural: there is no way to export a handler that skipped it.
* `tests/unit/codebaseConventions.unit.spec.ts` refuses a route that exports
* `POST` without going through this.
*
* The schema is parsed inside the same `try` as the handler, so a malformed
* field keeps mapping to the route's own `genericMessage`, as before.
*/
export const campaignJsonMutationRoute =
<TBody, TResponse>(
config: CampaignJsonMutationRouteConfig<TBody>,
handler: (body: TBody) => Promise<NextResponse<TResponse>>,
) =>
async (request: Request): Promise<NextResponse<TResponse | CampaignJsonErrorBody>> => {
if (!isSameOriginRequest(request)) {
return NextResponse.json(
{ status: 'error', message: 'Requisição inválida.' },
{ status: 403 },
)
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
return await handler(config.bodySchema.parse(parsed.body))
} catch (error) {
return campaignJsonMutationErrorResponse(error, config)
}
}

View file

@ -2,7 +2,9 @@
* CSRF guard for cookie-authenticated JSON route handlers (auto-save popovers):
* a same-origin browser request either omits `Origin` (same-site navigation)
* or sends one matching the request URL; anything else is rejected before the
* body is even parsed. Shared by every `POST /campanha/**` JSON endpoint.
* body is even parsed. Its single consumer is `campaignJsonMutationRoute`,
* which is how every `POST /campanha/**` endpoint gets it: applying the policy
* here and the sharing there is what stops a new route from omitting it.
*/
export const isSameOriginRequest = (request: Request): boolean => {
const origin = request.headers.get('origin')

View file

@ -0,0 +1,269 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
const errorToast = vi.fn()
vi.mock('sonner', () => ({ toast: { error: (message: string) => errorToast(message) } }))
/**
* B32+ F1: this state machine (debounce, coalescing, abort of a superseded
* request, revert, and the flush that closing performs) shipped three times
* inside three components, where only the e2e could reach it. Extracting it is
* what makes it testable at this level — so these cases are the precondition of
* the refactor, not decoration.
*/
type TestResponse =
| { status: 'success'; message: string; saved: string }
| { status: 'error'; message: string }
const ENDPOINT = '/campanha/municipios/political-trend'
const ERROR_MESSAGE = 'Não foi possível salvar. Tente novamente.'
const DELAY = 600
const jsonResponse = (payload: TestResponse, init?: { ok?: boolean }) => ({
ok: init?.ok ?? true,
json: async () => payload,
})
const success = (saved: string) => jsonResponse({ status: 'success', message: 'Salvo.', saved })
const renderAutosave = (value = 'inicial') =>
renderHook(
(props: { value: string }) =>
useCampaignCellAutosave<string, TestResponse>({
value: props.value,
equals: (left, right) => left === right,
endpoint: ENDPOINT,
buildBody: (next) => ({ value: next }),
readSaved: (payload) => payload.saved,
errorMessage: ERROR_MESSAGE,
pendingMessage: 'Salvando.',
}),
{ initialProps: { value } },
)
const fetchMock = vi.fn()
const requestBodies = () =>
fetchMock.mock.calls.map((call) => JSON.parse(String(call[1].body)) as { value: string })
beforeEach(() => {
vi.useFakeTimers()
fetchMock.mockReset()
fetchMock.mockResolvedValue(success('salvo'))
errorToast.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('useCampaignCellAutosave', () => {
it('waits out the debounce before posting', async () => {
const { result } = renderAutosave()
act(() => result.current.change('editado', DELAY))
expect(result.current.value).toBe('editado')
expect(result.current.statusMessage).toBe('Alterações serão salvas automaticamente.')
act(() => void vi.advanceTimersByTime(DELAY - 1))
expect(fetchMock).not.toHaveBeenCalled()
await act(async () => void vi.advanceTimersByTime(1))
expect(requestBodies()).toEqual([{ value: 'editado' }])
expect(result.current.statusMessage).toBe('')
})
it('coalesces keystrokes into a single request', async () => {
const { result } = renderAutosave()
act(() => result.current.change('e', DELAY))
act(() => void vi.advanceTimersByTime(DELAY - 50))
act(() => result.current.change('ed', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
expect(requestBodies()).toEqual([{ value: 'ed' }])
})
it('cancels the pending save when the value returns to the committed one', async () => {
const { result } = renderAutosave()
act(() => result.current.change('editado', DELAY))
act(() => result.current.change('inicial', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
expect(fetchMock).not.toHaveBeenCalled()
expect(result.current.statusMessage).toBe('')
})
it('flushes the pending change when the overlay closes instead of dropping it', async () => {
const { result } = renderAutosave()
act(() => result.current.onOpenChange(true))
act(() => result.current.change('editado', DELAY))
await act(async () => result.current.onOpenChange(false))
expect(requestBodies()).toEqual([{ value: 'editado' }])
})
it('does not re-post the payload already in flight when blur and close both flush', async () => {
let settleSave: (value: unknown) => void = () => {}
fetchMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
settleSave = resolve
}),
)
const { result } = renderAutosave()
act(() => result.current.onOpenChange(true))
act(() => result.current.change('editado', DELAY))
// Blur flushes…
act(() => result.current.flush())
// …and closing flushes again before the first request has answered.
act(() => result.current.onOpenChange(false))
expect(fetchMock).toHaveBeenCalledTimes(1)
await act(async () => settleSave(success('editado')))
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(result.current.value).toBe('editado')
})
it('ignores a response overtaken by a newer save', async () => {
let resolveFirst: (value: unknown) => void = () => {}
fetchMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
}),
)
fetchMock.mockResolvedValueOnce(success('segundo'))
const { result } = renderAutosave()
act(() => result.current.change('primeiro', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
act(() => result.current.change('segundo', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
await act(async () => resolveFirst(success('primeiro')))
expect(result.current.value).toBe('segundo')
expect(result.current.errorMessage).toBeNull()
})
it('reverts on failure and toasts only once the overlay is closed', async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{ status: 'error', message: 'Você não administra este município.' },
{
ok: false,
},
),
)
const { result } = renderAutosave()
act(() => result.current.onOpenChange(true))
act(() => result.current.change('editado', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
expect(result.current.value).toBe('inicial')
expect(result.current.errorMessage).toBe('Você não administra este município.')
// Still open: the inline Alert is the closer channel.
expect(errorToast).not.toHaveBeenCalled()
act(() => result.current.change('outra', DELAY))
await act(async () => result.current.onOpenChange(false))
expect(errorToast).toHaveBeenCalledWith('Você não administra este município.')
expect(result.current.value).toBe('inicial')
})
it('collapses a failure without a message of its own into the caller error', async () => {
fetchMock.mockRejectedValue(new Error('network down'))
const { result } = renderAutosave()
act(() => result.current.change('editado', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
expect(result.current.errorMessage).toBe(ERROR_MESSAGE)
expect(result.current.value).toBe('inicial')
})
it('sends a revert issued while a different payload is in flight', async () => {
let settleFirst: (value: unknown) => void = () => {}
fetchMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
settleFirst = resolve
}),
)
fetchMock.mockResolvedValueOnce(success('inicial'))
const { result } = renderAutosave()
act(() => result.current.change('editado', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
// Back to the value the server still holds, while `editado` is unanswered:
// "settled" has to mean the payload in flight, or nothing is sent and the
// first response repaints the cell with the value the user just undid.
act(() => result.current.change('inicial', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
await act(async () => settleFirst(success('editado')))
expect(requestBodies()).toEqual([{ value: 'editado' }, { value: 'inicial' }])
expect(result.current.value).toBe('inicial')
})
it('clears a past failure as soon as the value changes again', async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ status: 'error', message: 'Recusado.' }, { ok: false }),
)
const { result } = renderAutosave()
act(() => result.current.onOpenChange(true))
act(() => result.current.change('editado', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
expect(result.current.statusMessage).toBe('Recusado.')
// `errorMessage` outranks everything in `statusMessage`, so leaving it set
// would announce the old failure over every later keystroke.
act(() => result.current.change('corrigido', DELAY))
expect(result.current.errorMessage).toBeNull()
expect(result.current.statusMessage).toBe('Alterações serão salvas automaticamente.')
})
it('resolves an updater against the latest value, not the render that scheduled it', async () => {
const { result } = renderAutosave()
act(() => {
result.current.change('a', DELAY)
result.current.change((current) => `${current}b`, DELAY)
})
await act(async () => void vi.advanceTimersByTime(DELAY))
expect(requestBodies()).toEqual([{ value: 'ab' }])
})
it('adopts a server value that changed from outside, without posting it back', async () => {
const { result, rerender } = renderAutosave()
await act(async () => rerender({ value: 'do servidor' }))
expect(result.current.value).toBe('do servidor')
expect(fetchMock).not.toHaveBeenCalled()
act(() => result.current.change('do servidor', DELAY))
await act(async () => void vi.advanceTimersByTime(DELAY))
expect(fetchMock).not.toHaveBeenCalled()
})
})

View file

@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
@ -31,6 +32,12 @@ type OverlayCase = {
triggerLabel: string
/** Text the Drawer header must carry beyond the município name. */
drawerTitle: string
/**
* Label of the sheet's primary footer action, for the cells whose commit is
* an explicit submit (B32+ F5). A footer REPLACES the default "Fechar", so
* the two assertions are exclusive.
*/
footerLabel?: string
element: (variant: CampaignCellEditOverlayVariant) => ReactElement
}
@ -80,6 +87,7 @@ const overlayCases: OverlayCase[] = [
name: 'nível de envolvimento',
triggerLabel: `Nível de envolvimento de ${MUNICIPALITY_NAME}: N3 · Rede + agenda`,
drawerTitle: 'Registrar nível de envolvimento',
footerLabel: 'Registrar movimento',
element: (variant) =>
createElement(MunicipalityListLevelControl, {
municipalityID: 1,
@ -90,6 +98,25 @@ const overlayCases: OverlayCase[] = [
variant,
}),
},
{
name: 'sinal',
triggerLabel: `Registrar sinal em ${MUNICIPALITY_NAME} — Sem sinal`,
drawerTitle: 'Registrar sinal',
footerLabel: 'Registrar sinal',
element: (variant) =>
// `createElement`'s typing puts a required `children` in props, so the
// rest-argument form this rule prefers does not type-check here.
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListSignalControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant,
formAction: async () => ({}),
children: createElement('span', null, 'Sem sinal'),
}),
},
]
beforeAll(() => {
@ -117,7 +144,7 @@ afterAll(() => {
describe('campaign cell edit overlay', () => {
it.each(overlayCases)(
'opens $name in a Drawer that names the município on touch',
async ({ triggerLabel, drawerTitle, element }) => {
async ({ triggerLabel, drawerTitle, footerLabel, element }) => {
render(element('sheet'))
fireEvent.click(screen.getByRole('button', { name: triggerLabel }))
@ -132,8 +159,16 @@ describe('campaign cell edit overlay', () => {
// the virtual keyboard covering the sheet before it can be read. Awaited
// because the advisors sheet mounts `cmdk`, which delays it by a tick.
await waitFor(() => expect(document.activeElement).toBe(title))
// Swiping down is the gesture, but it is not an affordance anyone sees.
expect(dialog.textContent).toContain('Fechar')
const footer = dialog.querySelector('[data-slot="drawer-footer"]')
if (footerLabel) {
// A footer replaces the default dismissal: the commit is the primary
// action and "Cancelar" is what stands in for "Fechar".
expect(footer?.textContent).toContain(footerLabel)
expect(footer?.textContent).not.toContain('Fechar')
} else {
// Swiping down is the gesture, but it is not an affordance anyone sees.
expect(footer?.textContent).toContain('Fechar')
}
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull()
},
)
@ -159,6 +194,121 @@ describe('campaign cell edit overlay', () => {
},
)
/**
* B32+ F4. The region used to live inside the overlay, and closing is exactly
* what commits a draft — so the announcement that mattered most was the one
* that unmounted before it could be read. It now sits beside the trigger,
* which outlives the dialog.
*/
/**
* The other half of the same policy: a region mounted on every row would
* register ~250 polite live regions on one município page (5 controls × the
* card and table trees), so it appears with the first open — early enough for
* "Salvando…", which cannot happen before one.
*/
it('does not mount the live region until the overlay has been opened once', () => {
render(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
status: 'favoravel',
trendNote: null,
variant: 'popover',
}),
)
expect(document.querySelector('[aria-live="polite"]')).toBeNull()
fireEvent.click(
screen.getByRole('button', {
name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
}),
)
expect(document.querySelector('[aria-live="polite"]')).not.toBeNull()
})
it('keeps the live region mounted after the overlay closes', async () => {
const fetchMock = vi.fn(
() =>
new Promise(() => {
// Never settles: "Salvando…" is the state under test.
}),
)
vi.stubGlobal('fetch', fetchMock)
render(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
status: 'favoravel',
trendNote: null,
variant: 'popover',
}),
)
const trigger = screen.getByRole('button', {
name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
})
fireEvent.click(trigger)
const select = await screen.findByLabelText('Tendência')
fireEvent.change(select, { target: { value: 'desfavoravel' } })
// Dismissing flushes the debounced save, and the dialog goes with it.
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull())
await waitFor(() => {
const region = document.querySelector('[aria-live="polite"]')
expect(region?.textContent).toBe('Salvando tendência.')
})
})
/**
* The sheet's submit sits in the footer, OUTSIDE the `<form>` it belongs to —
* associated by the standard `form` attribute. This case is what says that
* association still reaches React's action (B32+ F5); without it the button
* would look right and do nothing.
*/
it('submits the signal form from a button rendered in the sheet footer', async () => {
const formAction = vi.fn(async () => ({}))
render(
// `createElement`'s typing puts a required `children` in props, so the
// rest-argument form this rule prefers does not type-check here.
// eslint-disable-next-line react/no-children-prop
createElement(MunicipalityListSignalControl, {
municipalityID: 1,
municipalitySlug: 'feira-de-santana',
municipalityName: MUNICIPALITY_NAME,
lastSignalAt: null,
variant: 'sheet',
formAction,
children: createElement('span', null, 'Sem sinal'),
}),
)
fireEvent.click(
screen.getByRole('button', { name: `Registrar sinal em ${MUNICIPALITY_NAME} — Sem sinal` }),
)
const dialog = await screen.findByRole('dialog')
// Both selects are `required`: an invalid form never fires submit, and the
// case would pass for the wrong reason.
fireEvent.change(screen.getByLabelText('Tipo do sinal'), { target: { value: 'invasao' } })
fireEvent.change(screen.getByLabelText('Fonte'), { target: { value: 'Liderança local' } })
const submit = dialog.querySelector<HTMLButtonElement>('[data-slot="drawer-footer"] button')
expect(submit?.textContent).toContain('Registrar sinal')
expect(submit?.getAttribute('form')).toBe(
dialog.querySelector('form')?.getAttribute('id') ?? null,
)
fireEvent.click(submit as HTMLButtonElement)
await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1))
})
/**
* B34's cell is the one caller that is a sheet at EVERY viewport: it picks its
* surface by `pointer:`, not by width, so it has no `variant` to sweep and

View file

@ -0,0 +1,121 @@
import { NextResponse } from 'next/server'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { CAMPAIGN_SESSION_EXPIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
/**
* B32+ F2: the same-origin check used to be a line each of the five quick-edit
* routes wrote by hand, and the one that forgot it would have failed OPEN. It
* is now the wrapper's, so these cases guard it once for all of them — plus the
* error contract the routes had before the extraction, which must not have
* moved.
*/
const SAFE_MESSAGE = 'Você não administra este município.'
const GENERIC_MESSAGE = 'Não foi possível salvar. Verifique seu acesso e tente novamente.'
const bodySchema = z.object({ municipalityId: z.number().int().positive() })
const buildRoute = (handler: (body: { municipalityId: number }) => Promise<NextResponse>) =>
campaignJsonMutationRoute(
{ bodySchema, safeMessages: [SAFE_MESSAGE], genericMessage: GENERIC_MESSAGE },
handler,
)
const postRequest = (body: unknown, headers?: HeadersInit) =>
new Request('https://pt.jorgesolla.com.br/campanha/municipios/political-trend', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
describe('campaignJsonMutationRoute', () => {
it('answers a cross-origin POST with 403 before parsing or calling the handler', async () => {
const handler = vi.fn()
const response = await buildRoute(handler)(
postRequest({ municipalityId: 1 }, { Origin: 'https://evil.example' }),
)
expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: 'Requisição inválida.',
})
expect(handler).not.toHaveBeenCalled()
})
it('accepts an Origin matching the request URL', async () => {
const route = buildRoute(async ({ municipalityId }) =>
NextResponse.json({ status: 'success', municipalityId }),
)
const response = await route(
postRequest({ municipalityId: 7 }, { Origin: 'https://pt.jorgesolla.com.br' }),
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ status: 'success', municipalityId: 7 })
})
it('answers a malformed body with the shared 400', async () => {
const handler = vi.fn()
const response = await buildRoute(handler)(postRequest('{ not json'))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: 'Corpo da requisição inválido.',
})
expect(handler).not.toHaveBeenCalled()
})
it('collapses a schema violation into the route generic message, as before', async () => {
const handler = vi.fn()
const response = await buildRoute(handler)(postRequest({ municipalityId: 'abc' }))
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: GENERIC_MESSAGE,
})
expect(handler).not.toHaveBeenCalled()
})
it('lets a safe domain message through and collapses anything else', async () => {
const safe = await buildRoute(async () => {
throw new Error(SAFE_MESSAGE)
})(postRequest({ municipalityId: 1 }))
expect(safe.status).toBe(400)
await expect(safe.json()).resolves.toEqual({ status: 'error', message: SAFE_MESSAGE })
const leaky = await buildRoute(async () => {
throw new Error('column "engagement_level" does not exist')
})(postRequest({ municipalityId: 1 }))
expect(leaky.status).toBe(400)
await expect(leaky.json()).resolves.toEqual({ status: 'error', message: GENERIC_MESSAGE })
})
it('answers an expired session with 401', async () => {
const response = await buildRoute(async () => {
throw new Error(CAMPAIGN_SESSION_EXPIRED_MESSAGE)
})(postRequest({ municipalityId: 1 }))
expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({
status: 'error',
message: CAMPAIGN_SESSION_EXPIRED_MESSAGE,
})
})
it('returns a handler response untouched, status included', async () => {
const response = await buildRoute(async () =>
NextResponse.json({ status: 'blocked', violations: [] }, { status: 409 }),
)(postRequest({ municipalityId: 1 }))
expect(response.status).toBe(409)
await expect(response.json()).resolves.toEqual({ status: 'blocked', violations: [] })
})
})

View file

@ -168,6 +168,46 @@ describe('campaign formActions convention', () => {
})
})
describe('campaign JSON mutation route convention', () => {
// The cells' quick edits POST to cookie-authenticated route handlers, so each
// one needs the same-origin check. Written by hand it is a line a new route
// forgets, and forgetting it fails OPEN — hence the wrapper (B32+ F2), and
// hence this sweep, which is what stops the sixth route from opting out.
//
// It sweeps all of `src/app` and not just `(campaign)`: a cookie-authenticated
// endpoint written under `api/` would be exactly as exposed and exactly as
// invisible, so the exceptions are declared here with their reason instead of
// being implied by a folder.
const allowlist = new Map<string, string>([
['src/app/(payload)/api/[...slug]/route.ts', "re-export of Payload's own handler and auth"],
['src/app/(payload)/api/graphql/route.ts', "re-export of Payload's own handler and auth"],
[
'src/app/(frontend)/api/revalidate/route.ts',
'server-to-server: authenticated by a secret header, deliberately callable cross-origin',
],
])
it('builds every POST route under src/app with campaignJsonMutationRoute', () => {
const offenders = walkSourceFiles(resolve(repoRoot, 'src/app'), ['.ts'])
.filter((file) => basename(file) === 'route.ts')
.map(repoPath)
.filter((path) => !allowlist.has(path))
.filter((path) => {
const source = readFileSync(resolve(repoRoot, path), 'utf8')
// GET-only handlers (manifest, service worker) have no cookie-authed
// mutation to guard — matched on the export and not the bare word, so a
// GET route whose comment says "POST" is not a false offender. On the
// positive side the binding is what is matched, not a mention of the
// wrapper, which stops a file from pairing one wrapped handler with a
// second hand-rolled one.
if (!/export (const|async function) POST\b/.test(source)) return false
return !/export const POST = campaignJsonMutationRoute\(/.test(source)
})
expect(offenders, 'build the handler with campaignJsonMutationRoute').toEqual([])
})
})
describe('public site metadata global access', () => {
// An empty `metadata` global (fresh DB / poisoned unstable_cache) used to
// crash `next build` via raw field access. Every `getCachedGlobal('metadata')`