B17: column picker on the seven CampaignTable list surfaces.
The picker shipped as a capability of CampaignTable rather than a municípios feature, so every list that already uses the shared table got it: pass `columnVisibility` and the table mounts the menu and filters the columns. Persistence is a cookie, not localStorage, and the reason is a type rather than a preference: CampaignTableColumn.cell is a function, so the column array never crosses the server/client boundary and the client cannot rebuild the table without it. The server filters, so a hidden column does not reach the RSC payload either. `campaign_columns` carries a listId -> hidden ids map in a compact form with no JSON and no percent-encoding, because document.cookie and Next's cookies() disagree about decoding. Closing the menu is the commit. The first cut debounced at 400 ms, which batches a burst rather than a reading pace, so unchecking four columns while reading their labels was four full-route refreshes — on /campanha/liderancas each one re-pays the ~229 ms / 371-statement render already ledgered P1. The timer survives at 3 s as a safety net for a menu left open; the price is that the table behind the menu no longer updates per checkbox. `defaultVisible` is gone: it was true on 10 columns and false on none, and storing only the hidden set is what makes a newly shipped column visible to everyone who never touched the picker. In exchange `label` is required, which made `head` optional — 24 columns had a head that only restated the new label. Each surface declares one mandatory column, re-filtered on the server so a stale cookie cannot leave a row without its only linked cell. The bug review caught is this codebase's recurring one: the RSC payload of toggle A arrives inside the window of toggle B, and adopting the server there undid B and then committed the set without it. Reconciliation now runs only when no write is pending, pinned red against the previous code. Municipality column ids are typed against MunicipalityListColumnId now, so the id, the B22 description and the picker label are one key; the union member `nivel` was renamed `level` to match the id and the ?level= filter. The picker's own content comparison is sameIdSet, widened to string ids.
This commit is contained in:
parent
c66c0b4668
commit
155f6492d9
30 changed files with 1073 additions and 159 deletions
|
|
@ -27,7 +27,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
|
|||
## Sistema de listas (Pass 2 W1 — use, não reinvente)
|
||||
|
||||
- URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18).
|
||||
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `head`, `cell`, seams `mandatory`/`defaultVisible` p/ B17); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`).
|
||||
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `<CampaignTableHead>{label}</CampaignTableHead>`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível.
|
||||
- Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle).
|
||||
- Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`.
|
||||
- Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B17 entregue em código (2026-07-28):** seletor de colunas, e **nas sete superfícies** do sistema de listas (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios), não só em municípios — o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility`, devolvida inteira por `readCampaignColumnVisibility(listId)`, liga o controle e filtra), então o custo marginal por lista foi `label` nas colunas e uma prop. Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é `ui/Table` cru com `table-fixed`. **Duas premissas do plano de 24/07 morreram.** (1) `localStorage` — e o motivo é de tipo, não de gosto: `CampaignTableColumn.cell` é **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `<style>` por lista. Virou o cookie `campaign_columns` (path `/campanha`, `SameSite=Lax`, 1 ano, legível pelo cliente porque é ele quem escreve) com um mapa `listId → ids ocultos` em formato compacto (`municipios:kind~trend|demandas:requester`), **sem `JSON` e sem percent-encoding** — `document.cookie` e o `cookies()` do Next discordam sobre decodificação, e todo caractere usado é legal num cookie-value pela RFC 6265; o servidor filtra, então a coluna oculta **não viaja no payload RSC**. O preço é um `router.refresh()` por sessão de edição — **fechar o menu é o commit**, com timer de 3 s só como rede de segurança; o debounce de 400 ms do primeiro corte agrupava rajada, não ritmo de leitura, e cada perda re-pagava os ~229 ms / 371 statements de `/campanha/liderancas` — dentro da transição compartilhada, com o resultado dimando como em qualquer filtro dessas telas. (2) O seam `defaultVisible` **morreu**: era `true` em 10 colunas de `TerritoryListColumns` e `false` em nenhuma, e guardar só o conjunto de **ocultas** é o que faz coluna nova nascer visível — contrato que E9 ✓ e E14 ✓ já assumiam. Em troca, `label: string` virou **obrigatório** em `CampaignTableColumn` (o picker precisa de rótulo serializável), preenchido em ~55 definições — e como em 24 colunas o `head` passou a só repetir esse `label`, o `/simplify` tornou `head` **opcional**, com default `<CampaignTableHead>{label}</CampaignTableHead>`; quatro arquivos deixaram de importar `CampaignTableHead`. `mandatory` é **refiltrado no servidor** (`resolveVisibleColumns`), para um cookie velho não deixar a linha sem a única célula linkada; no menu a coluna aparece desabilitada com o motivo. **O bug real que a revisão pegou** é o de sempre nesta base em roupa nova: o payload RSC do toggle A chega dentro da janela de debounce de B, e adotar o servidor ali desfazia B **e commitava o conjunto sem ele** — a reconciliação passou a rodar só sem escrita pendente, pinada em `campaignColumnPicker.unit.spec.ts` (verificado vermelho contra o código anterior). **Lição de operação, não de código:** duas rodadas de e2e sugeriram que "`router.refresh()` não aplica o cookie"; era a máquina em **load 40** com outro worktree compilando, e o `expect` padrão de 5 s não cobre um round-trip RSC de rota pesada em dev com dois workers — as asserções de refresh ganharam orçamento próprio, e a suíte passa a 1 e a 2 workers. Sem migration, collection, action ou `Consent`. Plano as-built: [`seletor-colunas-lista-municipios.md`](../../../docs/plans/seletor-colunas-lista-municipios.md).
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Latent bug fixed for free:** `CampaignTableHead`'s `description` seam (**B22**) broke **any** RSC caller — `campaignHoverTooltipAlign`/`campaignHoverExplanationClassName` lived in the `'use client'` module of `CampaignHoverTooltip`, so the server was calling a client reference; they moved to `src/lib/campaignHoverTooltip.ts`, and the leaderships list is the first RSC header with an explanation. **Post-merge `/simplify` follow-up (same day), all measured:** the perf pass cut real CPU — `buildMunicipalityPortfolioChips` 4.2 → **0.78 ms** cold per 25-row table and **0.043 ms** on every later render (a `WeakMap` keyed on the index array, which one RSC builds once and all rows share, so it survives re-renders where a per-row `useMemo` would not), and search 4.59 → **0.077 ms** per keystroke (normalize the query once instead of inside the matcher for each of ~660 candidates; `matchesNormalizedAtWordStart` is the new shared entry point and `matchesAtWordStart` delegates to it). Also: `sameIdSet` replaced a hand-rolled sorted-key comparison (3rd call site of a single-purpose helper), `scrollIntoView` is keyboard-only (the mouse is already where it points — it was a forced layout per option the pointer crossed), and `revalidatePath` now uses the real delta (`changed`), so removing a município that was never linked no longer busts its route. **One reviewer suggestion was rejected by measurement, and it looked obvious:** dropping `sort: 'name'` from the loader ("nothing depends on the DB ordering") **broke the suggestion list** — `searchMunicipalityPortfolio` walks the index in arrival order and stops at `limit`, so the alphabetical suggestions were alphabetical only because of that `ORDER BY`. The B34 e2e caught it; the sort is back with the reason written beside it. **The finding that did NOT get fixed, because both fixes oppose each other:** the delivery costs **+58 kB** First Load JS on `/campanha/liderancas` (246 → 304) and **+42 kB** on `/campanha/assessores` (253 → 295), measured with two full builds against a stashed clean `main`. Assessores already imported the static Bahia catalog, so its +42 kB is the **cell**, not the catalog — only ~16 kB of lideranças' delta is the catalog reaching a new route. Pulling the catalog out of the browser fattens the payload of every chip toggle; shrinking the payload to `{id,slug}` keeps the bundle. Registered as **B34+** with the recommendation (payload-minimal) and its one real caveat. No migration, collection, `Consent` or URL change. **Debt with a trigger:** `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover and measuring machinery in the same table — **it**, not B37, is the extraction trigger for a relation-agnostic chip editor (B37 plugs the existing shared cell into a third relation without new machinery). **Measured, and now P1 in the ledger:** two e2e tests fail **deterministically at one worker on a clean `main` tree** (verified by stashing this whole delivery) — `campaignNearestMunicipality:156` and `campaignMunicipalities:394`. That is not the fichado 2-worker flake; do not dismiss a red `pnpm test:e2e` as flakiness without re-checking those two by name. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](docs/plans/chips-municipios-lista-liderancas.md).
|
||||
**Recently resolved (2026-07-27):** **`users.roles`** — Payload admin RBAC. Field `roles` (`admin` | `editor`) on `users` (migration `20260727_032523_add_users_roles`, backfill `admin`); `isPayloadAdmin` requires the `admin` role; editors open `/admin` and write `post`/`tag`/`media`; PII/`petition`/campaign collections stay admin-only. Unifying `users` + `campaignUser` was rejected (25 FKs; structural isolation). Plano: [`docs/plans/roles-admin-payload.md`](docs/plans/roles-admin-payload.md).
|
||||
**Recently resolved (2026-07-28):** **B17** Seletor de colunas — a "Colunas" picker shipped as a capability of `CampaignTable`, so it landed on **all seven** list surfaces (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios) rather than only on `/campanha/municipios`: pass `columnVisibility` (one value, from `readCampaignColumnVisibility(listId)`) and the table mounts `CampaignColumnPicker` and filters through `resolveVisibleColumns`. `/campanha/assessores` stays out — `AdvisorsTable` is still a raw `ui/Table` with `table-fixed`, and migrating it to the list system is separate work. **Two premises of the 2026-07-24 plan died, and the first one died on a type, not a preference:** `CampaignTableColumn.cell` is a **function**, so the column array never crosses the server/client boundary and the client cannot rebuild the table without it — hiding client-side would mean tagging every cell with `data-column` and injecting a `<style>` per list. Persistence is therefore the cookie `campaign_columns` (path `/campanha`, `SameSite=Lax`, one year, client-readable because the client writes it), carrying a `listId → hidden ids` map in a compact form (`municipios:kind~trend|demandas:requester`) with **no `JSON` and no percent-encoding** — `document.cookie` and Next's `cookies()` disagree about decoding, and every character used is legal in a cookie-value under RFC 6265. The server filters, so a hidden column **does not reach the RSC payload either**; the price is one `router.refresh()` per editing session inside the shared transition, dimming the results like every other filter on those screens. **Closing the menu is the commit**, not a debounce: the first cut used 400 ms, which batches a burst rather than a reading pace, so unchecking four columns while reading their labels was four full-route refreshes — and on `/campanha/liderancas` each one re-pays the ~229 ms / 371-statement render already ledgered P1. The timer survives at 3 s purely as a safety net for a Popover left open. The cost is stated because it is real: the table behind the menu no longer updates per checkbox. The second casualty is the `defaultVisible` seam, which is **gone**: it was `true` on 10 columns and `false` on none, and storing only the **hidden** set is what makes a newly shipped column visible to everyone who never touched the picker — the contract E9 and E14 already assumed. In exchange, `label: string` is now **required** on `CampaignTableColumn` (the picker needs a serializable label), filled in across ~55 column definitions — and since 24 of those columns then had a `head` that only restated the new `label`, `head` became **optional**, defaulting to `<CampaignTableHead>{label}</CampaignTableHead>`; declare it only for an alignment, a B22 `description`, or a head island of its own. Each surface declares one `mandatory` column, listed in the menu disabled with its reason and **re-filtered on the server**, so a stale cookie cannot leave a row without its only linked cell. **The real bug review caught is this codebase's recurring one in new clothing:** the RSC payload of toggle A arrives while B is still inside the debounce window, and adopting the server there undid B **and then committed the set without it**, losing it silently — reconciliation now runs only when no write is pending, pinned in `tests/unit/campaignColumnPicker.unit.spec.ts` (verified red against the previous code). **Worth knowing operationally:** two e2e rounds during the session suggested "`router.refresh()` does not apply the cookie". That was the machine at load average 40 with another worktree compiling — the picker's own spec passes at one and at two workers, and the lesson is that `expect`'s 5 s default does not cover a dev-mode RSC round-trip on a heavy list route with two workers, so the refresh assertions carry their own budget. **The P1 red-e2e ledger row also got wider and the way to read it changed:** `campaignMunicipalities` fails **five** tests at one worker on a stashed clean tree (`:56`, `:125`, `:292`, `:394`, `:446`) against four with this delivery applied, and the failing set differs between runs — so "check those two by name" no longer settles anything; compare the count against a stashed tree. No migration, collection, server action or `Consent`. Plano: [`docs/plans/seletor-colunas-lista-municipios.md`](docs/plans/seletor-colunas-lista-municipios.md).
|
||||
|
||||
**Recently resolved (2026-07-27):** **E14** Níveis de envolvimento N0–N4 — `municipality.engagementLevel` (staff-only select `n0…n4`) records **what the campaign decided to invest** in a município, next to the E8/E10 metrics that describe what it knows. It is also the **first production writer of `allocationDecision`** (C12 shipped 2026-07-24 with access and tests but no caller). Migration `20260727_161752_add_municipality_engagement_level` adds `engagementLevel` (indexed, **no default**), `levelNote` (textarea 2000) and `levelChangedAt` (indexed date, `readOnly`, stamped by a `beforeChange` hook only when the level actually changes — and cleared when the level is), plus the value `movimento` on the `allocationDecision.outcome` enum, because a level move is neither `aceita` nor `descarta` and pretending otherwise would poison E11's triage. **No backfill:** the field is born null on all 435 municípios — stamping N2 would assert a decision nobody made — so null renders "Sem nível" and filtering by it **is** the triage queue. Moving a município is a reallocation decision, so writes are `isCampaignUnrestricted` only (`canManageMunicipalityEngagementLevel` in `utilities/access/municipalities.ts`); the advisor reads it and proposes through a signal. `setMunicipalityEngagementLevelRecord` takes the advisory lock `municipality-engagement-level:{id}` **before** reading the current level (otherwise the snapshot's `from` is a guess under concurrency) and writes both documents in one transaction, with `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — the values the decision was made on, never the document. The hysteresis rules from research §6.8 live in the pure, client-safe `src/lib/engagementLevel.ts` (two-level jump needs a triangulated shock; three-week protection window after a promotion, against demotion only; one move per calendar month, derivable from `levelChangedAt` alone — no extra query), so the Popover shows the reason before submitting and the server re-runs them anyway. **The route has three states, not two:** `blocked` (409) returns the typed violations instead of collapsing into "could not save", because a blocked move is a decision the coordination may still take — the Popover keeps the draft and offers the override, which is recorded with exactly the rules it contradicted. **Explicit submit, not auto-save** — the exception `campanha-edit-where-you-see` already carves out for flows needing a note/confirmation, which also keeps this control out of the debounce machine **B32+** will audit. On the list: an 11th staff column with a numbered outline badge, plus filter `?level=`, which — unlike E10's derived `classe` — is **native**, because the field is stored and reaches the `where`; "sem nível" cannot ride inside an `in` and becomes an `or` branch with `{ exists: false }`. **The sort is not native**, though `n0…n4` does order lexicographically: Postgres puts NULLs FIRST on DESC, so with no backfill "N4 primeiro" would open on a page of "Sem nível". `nivel` joins `applyDerivedMunicipalitySort` and reuses `sortByNullableValue`, which sinks the absent level in both directions — the same treatment `sem_base` gets in `classe`. New E18 concept `nivel-de-envolvimento`. **Worth knowing:** with `overrideAccess: false` Payload **silently drops** a field the actor cannot write instead of rejecting the update, so the advisor int test asserts the level did **not** change rather than expecting a throw. Plano: [`docs/plans/niveis-de-envolvimento.md`](docs/plans/niveis-de-envolvimento.md).
|
||||
|
||||
**Recently resolved (2026-07-27):** **Build resiliente ao global `metadata` ausente** — `resolveSiteMetadata` in `src/utilities/seo.ts` (chain `global.URL` → `NEXT_PUBLIC_SITE_URL` → omit canonical/OG/JSON-LD url; textual defaults for empty global). All public SEO consumers (`layout`, `/abaixo-assinado/[id]`, `/[type]` tree) go through it; convention guard in `codebaseConventions.unit.spec.ts`. Plano: [`docs/plans/build-resiliente-metadata-ausente.md`](docs/plans/build-resiliente-metadata-ausente.md).
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ flowchart LR
|
|||
|
||||
- **Leituras** passam `user` + `overrideAccess: false` (bypass admin só documentado, ex. resolução de nomes sobre linhas já checadas).
|
||||
- **Escritas** saem de server actions em `src/app/(campaign)/campanha/actions/*`; multi-collection = transação Payload com `req: { transactionID }`. As `formActions.ts` por rota são cascas finas sobre os wrappers compartilhados: `runCampaignFormAction` (fica na página, estado de sucesso/erro) e `runCampaignRedirectFormAction` (cria e redireciona) em `campaignFormActionError.ts` — exceções documentadas nos próprios arquivos (planos, apoiadores/[id], convite login).
|
||||
- **Listas** usam o sistema do Pass 2 W1: parse/canonicalização de URL em `campaignListUrl.ts` (+ módulo por domínio, ex. `municipalityListUrl.ts`), colunas como dado em `CampaignTable` (`shared/CampaignTable.tsx`, seams `mandatory`/`defaultVisible` para B17), shells `CampaignSearchForm`/`CampaignFilterChips`/`CampaignListFooter`/`CampaignListEmptyState`/`CampaignListPagination`, pending compartilhado via `CampaignListPendingBoundary` (dima o RESULTADO, não o controle — princípio "Feel the action"). Contrato de URL congelado (B18 depende).
|
||||
- **Listas** usam o sistema do Pass 2 W1: parse/canonicalização de URL em `campaignListUrl.ts` (+ módulo por domínio, ex. `municipalityListUrl.ts`), colunas como dado em `CampaignTable` (`shared/CampaignTable.tsx`: `id`, `label` obrigatório, `head` opcional derivado do `label`, `cell`, `mandatory`), shells `CampaignSearchForm`/`CampaignFilterChips`/`CampaignListFooter`/`CampaignListEmptyState`/`CampaignListPagination`, pending compartilhado via `CampaignListPendingBoundary` (dima o RESULTADO, não o controle — princípio "Feel the action"). Contrato de URL congelado (B18 depende). **Visibilidade de coluna (B17) não é estado de URL:** é preferência de dispositivo no cookie `campaign_columns` (`lib/campaignColumnVisibility.ts` puro + `utilities/campaignColumnVisibilityCookie.ts` `server-only`), lida por quem renderiza a tabela — `readCampaignColumnVisibility(listId)` devolve id e ocultas como **um** valor, para que a chave lida e a escrita não possam divergir — e aplicada **no servidor** — a coluna oculta não chega ao payload RSC. Guarda-se só o conjunto de ocultas, então coluna nova nasce visível.
|
||||
- **Páginas de detalhe** roteiam tabs por querystring (`detailTabUi.ts` → `municipalityDetailTabUi`/`activityDetailTabUi`); cada tab é uma section RSC própria colocada na rota (`MunicipalityDetailTabs.tsx`, `ActionPlanOverviewTab.tsx`), streamando atrás de `Suspense` quando a leitura é pesada.
|
||||
|
||||
## Escada de caching (decidir nesta ordem)
|
||||
|
|
|
|||
|
|
@ -4,31 +4,31 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
|
|||
|
||||
## Debt Ledger
|
||||
|
||||
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
|
||||
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
|
||||
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | |
|
||||
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
|
||||
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
|
||||
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
|
||||
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
|
||||
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
|
||||
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
|
||||
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
|
||||
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
|
||||
| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:394` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card | |
|
||||
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
|
||||
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
|
||||
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
|
||||
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
|
||||
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
|
||||
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
|
||||
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | **P1** | open — measured 2026-07-26, re-measured 2026-07-27 (B34). A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins. Cheap adjacent win found in the same pass: `getAdvisorMunicipalityIds` (2 SQL / 10,7 ms) is a textbook `cache()` candidate — same request, same args, no dedup — and B34 added a call site in `liderancas/page.tsx` | |
|
||||
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
|
||||
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 |
|
||||
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | B34+ |
|
||||
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
|
||||
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
|
||||
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
|
||||
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | |
|
||||
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
|
||||
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
|
||||
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
|
||||
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
|
||||
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
|
||||
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
|
||||
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
|
||||
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
|
||||
| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed. **Widened 2026-07-28 (B17), same stash-and-compare method: it is not two tests but most of `campaignMunicipalities`** — five failed at one worker on the clean tree (`:56`, `:125`, `:292`, `:394`, `:446`) against four with the delivery applied, and the failing set is not stable between runs (`page.waitForURL` timeouts, a `getByLabel('Média')` strict-mode violation matching both the popover trigger and its input, a click whose target the bottom nav intercepts). Practical consequence for the next delivery: comparing the count of red tests against a stashed tree is the only way to read this spec — naming the two tests above is no longer enough | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:56,125,292,394,446` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`), widened 2026-07-28 (B17) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card. The strict-mode violation is a real ambiguity in the spec's own locator, fixable on its own | |
|
||||
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
|
||||
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
|
||||
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
|
||||
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
|
||||
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
|
||||
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
|
||||
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | **P1** | open — measured 2026-07-26, re-measured 2026-07-27 (B34). A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins. Cheap adjacent win found in the same pass: `getAdvisorMunicipalityIds` (2 SQL / 10,7 ms) is a textbook `cache()` candidate — same request, same args, no dedup — and B34 added a call site in `liderancas/page.tsx` | |
|
||||
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
|
||||
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 |
|
||||
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | B34+ |
|
||||
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
|
||||
|
||||
## Smell Inventory
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ Saldo do lote: **+564 / −802 linhas** em 15 arquivos, mais quatro novos (`useC
|
|||
- **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. **Gatilho:** quando aparecer um 2º auto-save com debounce que grave por server action, o transporte injetável passa a ter dois clientes e a recusa cai.
|
||||
|
||||
- **Onde o hook para, medido pelo B17 ✓ no rebase (2026-07-28).** `CampaignColumnPicker` é o 6º lugar com o esqueleto de temporizador (`schedule`/`cancel`/flush-ao-fechar + limpeza no unmount), e **não** adota `useCampaignCellAutosave`: ele não tem endpoint — escreve `document.cookie` e chama `router.refresh()` —, então `endpoint`/`buildBody`/`readSaved`/abort/revert não teriam o que fazer, e herdar rede que não se usa é o serviço genérico que `campanha-edit-where-you-see` proíbe. Ou seja, o corte deste item ficou no lugar certo: quem fala com rota ganhou a máquina inteira; quem não fala mantém ~15 linhas de temporizador. Um `useDebouncedCommit` só de mecânica ainda seria defensável (6º call site), mas é 3ª camada num item já entregue — **gatilho:** um 7º chamador sem rota. As duas entregas convergiram sozinhas no mesmo invariante, o que é o argumento mais forte a favor dele: **fechar é o commit**.
|
||||
- **A política de adoção de props ficou decidida — nos dois sentidos, e a diferença tem causa.** O hook adota num efeito guardado por `equals` contra `lastPropsRef`, **sem** gate de pendência, porque `committedRef`/`inFlightRef` já sabem "com o que o servidor vai terminar". O picker do B17 ✓ mantém o gate no render (`commitTimer.current === null`), porque não existe requisição em voo para comparar: entre a marcação e o commit não há nada escrito, e um payload RSC vindo de outro controle da mesma página desfaria a marcação e commitaria o conjunto sem ela. Regra derivável: **gate de pendência quando não há request em voo para servir de referência; `committedRef` quando há.**
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Fill-ins abertos — **B32+**)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,38 @@
|
|||
# B17 — Seletor de colunas na lista de Municípios
|
||||
# B17 — Seletor de colunas (todas as listas do sistema `CampaignTable`)
|
||||
|
||||
Status: rascunho — **costuras prontas desde o Pass 2 W1 (2026-07-25):** as colunas da lista são dado (`CampaignTable`, `CampaignTableColumn.id`/`mandatory`/`defaultVisible`); o seletor vira um toggle de visibilidade sobre esses ids + `localStorage`
|
||||
Atualizado em: 2026-07-24
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B17; superfície de coordenação)
|
||||
Impeccable: B — encaixe em `MunicipalityList` / barra slim de `/campanha/municipios`; sem rota nova
|
||||
Appetite: ~0,5–1 dia eng; Popover de colunas + persistência local + render condicional desktop; sem migration, sem collection, sem Consent
|
||||
Responsável: —
|
||||
Status: **entregue 2026-07-28** — e com escopo maior e mecanismo diferente do que este plano previa; ver "Como ficou" abaixo antes de ler o resto, que é o desenho de 2026-07-24 preservado como registro
|
||||
Atualizado em: 2026-07-28
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (B17; superfície de coordenação)
|
||||
Impeccable: B — encaixe no `CampaignTable`; sem rota nova
|
||||
Appetite: revisado para ~1,3 dia eng (o plano previa 0,5–1 dia para **uma** lista; sete superfícies + a peça compartilhada + `label` em ~55 definições de coluna explicam a diferença); sem migration, sem collection, sem Consent
|
||||
|
||||
## Como ficou (as-built, 2026-07-28)
|
||||
|
||||
**Escopo: sete superfícies, não uma.** O picker nasceu como capacidade do `CampaignTable` — uma prop `columnVisibility` liga o controle e filtra as colunas — então municípios, lideranças, dobradinhas, organizações, demandas, apoiadores e territórios ganharam a mesma barra "Colunas" no mesmo lugar (`hidden md:flex`, alinhada à direita, acima da tabela). Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é um `ui/Table` cru com `table-fixed` — migrá-lo ao sistema de listas é outro trabalho.
|
||||
|
||||
**Persistência: cookie, não `localStorage`.** A decisão travada abaixo ("`localStorage`, sem RSC round-trip") **morreu na implementação, por um fato de tipo, não de gosto**: `CampaignTableColumn.cell` é uma **função**, então o array de colunas não atravessa a fronteira server/client e o cliente não tem como reconstruir a tabela sem ela; esconder no cliente exigiria marcar cada célula com `data-column` e injetar `<style>` por lista. Persistência virou o cookie `campaign_columns` (path `/campanha`, `SameSite=Lax`, 1 ano, legível pelo cliente porque é ele quem escreve), com um mapa `listId → ids ocultos` num formato compacto (`municipios:kind~trend|demandas:requester`) — **sem `JSON` e sem percent-encoding**, porque `document.cookie` e o `cookies()` do Next discordam sobre decodificação e todo caractere usado é legal num cookie-value pela RFC 6265. O servidor filtra: a coluna oculta **não é renderizada nem viaja no payload RSC**. O custo é um `router.refresh()` por sessão de edição — **fechar o menu é o commit**; o timer de 3 s é só rede de segurança para um Popover deixado aberto —, dentro da transição compartilhada — o resultado dima como em qualquer filtro dessas telas.
|
||||
|
||||
**O seam `defaultVisible` morreu.** Era declarado `true` em 10 colunas de `TerritoryListColumns` e `false` em nenhuma. Guardar apenas o **conjunto de ocultas** é o que garante que uma coluna nova nasça visível para quem nunca tocou no picker — o contrato que E9 ✓ e E14 ✓ já assumiam. Em troca, `label: string` passou a ser **obrigatório** em `CampaignTableColumn`: o picker precisa de um rótulo serializável, e `head` continua `ReactNode` opaco.
|
||||
|
||||
**`mandatory` é refiltrado no servidor.** Cada superfície declara a sua coluna de identidade como `mandatory`; ela aparece no menu, desabilitada e com o motivo ("sempre visível"), e `resolveVisibleColumns` a mantém **mesmo que o cookie diga o contrário** — um cookie velho não pode deixar ninguém sem a única célula linkada da linha.
|
||||
|
||||
**O bug que a revisão pegou** é o de sempre nesta base, em roupa nova: o payload RSC do toggle A chega enquanto B ainda está na janela de debounce; adotar o servidor ali desfazia B **e depois commitava o conjunto sem ele**, perdendo-o em silêncio. A reconciliação passou a rodar só quando não há escrita pendente, pinada em `tests/unit/campaignColumnPicker.unit.spec.ts` (verificado vermelho contra o código anterior).
|
||||
|
||||
**Nota de operação, não de código:** durante a sessão, duas rodadas de e2e sugeriram que "`router.refresh()` não aplica o cookie". Era a máquina em load 40 com outro worktree compilando — a suíte passa a 1 e a 2 workers, e a lição registrada é que o `expect` padrão de 5 s não cobre um round-trip RSC de rota pesada em dev com dois workers (as asserções de refresh têm orçamento próprio).
|
||||
|
||||
**O `/simplify` mudou três coisas do primeiro corte, e uma delas era uma premissa escrita em comentário.** (1) `listId` + `hiddenColumnIds` viraram **um** valor, `columnVisibility: CampaignColumnVisibility`, devolvido inteiro por `readCampaignColumnVisibility(listId)`: cada superfície nomeia a sua lista **uma vez**. Como duas props, o cookie podia ser lido numa chave e escrito noutra — ambas válidas, nada quebrando, e o sintoma seria "o picker não faz nada". (2) `head` passou a ser **opcional**, com default `<CampaignTableHead>{label}</CampaignTableHead>`: em 24 colunas o `head` só repetia o `label` recém-adicionado, e essa duplicação nascia com este item. Quatro arquivos deixaram de importar `CampaignTableHead` — medida de que a duplicação era real. O `label` continua obrigatório, e a única divergência legítima entre os dois está documentada no tipo: um header pode ser telegráfico porque está em cima dos próprios dados (municípios: "2022"), enquanto a mesma palavra sozinha num menu de nomes de coluna não diz nada ("Votação 2022"). (3) A caption de municípios decidia se cita a coluna "2022" com `hiddenColumnIds.includes('votos')` — uma segunda implementação de "esta coluna está visível?", cega a `mandatory`; agora pergunta a `resolveVisibleColumns`, a mesma função que a tabela usa.
|
||||
|
||||
**A segunda rodada do `/simplify` (reuso e performance) achou duplicação que já tinha dono.** (1) O `contentKey` (sort + join) do picker era `sameIdSet` reescrito — o helper cuja docstring descreve **exatamente** esta política; ele foi generalizado para `string | number`, o picker virou o 4º chamador e um `useState` que guardava string derivada morreu junto. (2) Os `label` eram uma segunda fonte de verdade onde já existia módulo de rótulos: territórios acertou (`territoryListSortLabels`), municípios e dobradinhas não. Agora `municipalityColumnLabels` — em `municipalityListUrl.ts`, porque `municipalityLabels.ts` já é importado por ele e o contrário seria ciclo — é `Record<MunicipalityListColumnId, string>` e cita `municipalityListSortLabels` nas dez que **precisam** bater com o header (é de lá que `MunicipalitySortableHead` resolve o texto), deixando as duas divergências de propósito escritas com o motivo ao lado (`votos`, `lastSignal`). Na esteira, o `id` das colunas de municípios passou a ser **tipado** contra `MunicipalityListColumnId`, o que exigiu alinhar o membro `nivel` → `level`: o id da coluna e o filtro `?level=` já eram `level` (o sort continua `nivel`, e as duas grafias de URL estão congeladas pelo B18). Id, descrição (B22 ✓) e rótulo do picker são a mesma chave — coluna nova não nasce sem os três, e renomear o header renomeia a entrada do menu. (3) Dois nits com precedente a três arquivos de distância: `aria-label` no `PopoverContent` (o Radix dá `role="dialog"` sem nome — a mesma correção que `CampaignCellEditOverlay` já documenta) e `secure` no cookie quando o protocolo é https, alinhando com `campaignCookieOptions`.
|
||||
|
||||
**A cadência do refresh mudou por medição, e o número que importa não é o do debounce.** O primeiro corte commitava com debounce de 400 ms, o que agrupa **rajada**, não sessão de leitura: desmarcar quatro colunas lendo os rótulos entre os cliques eram quatro `router.refresh()` de rota inteira. Em `/campanha/liderancas` cada um re-paga os ~229 ms / 371 statements fichados como P1 no `TECH-DEBT` (367 deles o mesmo N+1 de access), com o resultado dimando a cada clique. Agora **fechar o menu é o commit** e o timer virou rede de segurança de 3 s para quem deixa o Popover aberto. O que se perde está dito porque é real: a tabela atrás do menu não atualiza mais a cada marcação — a checkbox é o feedback do controle, o refresh é o resultado. O caso "1 s entre duas marcações = **um** refresh" está pinado em `campaignColumnPicker.unit.spec.ts` com fake timers (verificado vermelho contra os 400 ms), junto do caso da rede de segurança.
|
||||
|
||||
**Rejeitado com razão medida.** `CampaignTable` ler o cookie sozinho — o que apagaria as 7 leituras nas páginas e as 3 props de passagem — funcionaria, porque todo chamador é RSC; mas `tests/unit/campaignTable.unit.spec.ts` renderiza a tabela com `renderToStaticMarkup`, que não espera componente async, e foi exatamente esse fato que fez as listas voltarem a ser síncronas durante a implementação. Trocar cobertura por menos props não paga. Também rejeitada a casca compartilhada de painel de Popover: 2 chamadores, e o 3º corpo de multi-seleção já é gatilho registrado no B32+.
|
||||
|
||||
**Lacuna conhecida (fill-in, não bug):** o picker é `hidden md:flex` porque municípios e apoiadores escondem esta tabela abaixo de `md` e mostram cards. As **outras cinco** superfícies renderizam a tabela em qualquer largura, então num celular elas rolam a tabela sem ter o controle. Dar picker a elas é um seam por caller, não um breakpoint: mover o `className` do caller para um wrapper externo quebraria o `overflow-visible` de que a coluna sticky de B41 depende. Gatilho: alguém usar essas listas no celular de verdade.
|
||||
|
||||
Arquivos: `src/lib/campaignColumnVisibility.ts` (puro, client-safe), `src/utilities/campaignColumnVisibilityCookie.ts` (`server-only`), `src/components/campaign/shared/CampaignColumnPicker.tsx`, seams em `src/components/campaign/shared/CampaignTable.tsx`.
|
||||
|
||||
## Desenho original (2026-07-24, preservado)
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -18,6 +18,7 @@ import { SupporterListOverview } from '@/components/campaign/supporter/Supporter
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
|
||||
import { loadSupportersPageData } from '@/utilities/supporterPageData'
|
||||
import {
|
||||
|
|
@ -43,12 +44,14 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
const { result, state, redirectHref, municipalityOptions, overview } =
|
||||
await loadSupportersPageData(payload, user, rawSearchParams)
|
||||
if (redirectHref) redirect(redirectHref)
|
||||
const columnVisibility = await readCampaignColumnVisibility('apoiadores')
|
||||
|
||||
const listBody = result.docs.length ? (
|
||||
<>
|
||||
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
|
||||
<SupporterList
|
||||
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
|
||||
columnVisibility={columnVisibility}
|
||||
/>
|
||||
<CampaignListFooter
|
||||
totalDocs={result.totalDocs}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ import {
|
|||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -27,6 +23,7 @@ import {
|
|||
} from '@/lib/schemas/campaignDemand'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import {
|
||||
buildDemandListHref,
|
||||
loadDemandListPageData,
|
||||
|
|
@ -54,8 +51,8 @@ const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
|
|||
const demandColumns: Array<CampaignTableColumn<DemandRowViewModel>> = [
|
||||
{
|
||||
id: 'title',
|
||||
label: 'Demanda',
|
||||
mandatory: true,
|
||||
head: <CampaignTableHead>Demanda</CampaignTableHead>,
|
||||
cellClassName: 'max-w-64 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<Link
|
||||
|
|
@ -68,31 +65,31 @@ const demandColumns: Array<CampaignTableColumn<DemandRowViewModel>> = [
|
|||
},
|
||||
{
|
||||
id: 'kind',
|
||||
head: <CampaignTableHead>Tipo</CampaignTableHead>,
|
||||
label: 'Tipo',
|
||||
cell: (row) => campaignDemandKindLabels[row.kind],
|
||||
},
|
||||
{
|
||||
id: 'municipality',
|
||||
head: <CampaignTableHead>Município</CampaignTableHead>,
|
||||
label: 'Município',
|
||||
cellClassName: 'text-muted-foreground',
|
||||
cell: (row) => row.municipalityName,
|
||||
},
|
||||
{
|
||||
id: 'requester',
|
||||
head: <CampaignTableHead>Solicitante</CampaignTableHead>,
|
||||
label: 'Solicitante',
|
||||
cellClassName: 'text-muted-foreground',
|
||||
cell: (row) => row.requesterName ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
head: <CampaignTableHead>Status</CampaignTableHead>,
|
||||
label: 'Status',
|
||||
cell: (row) => (
|
||||
<Badge variant={statusVariant[row.status]}>{campaignDemandStatusLabels[row.status]}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
head: <CampaignTableHead>Aberta em</CampaignTableHead>,
|
||||
label: 'Aberta em',
|
||||
cell: (row) => dateFormatter.format(new Date(row.createdAt)),
|
||||
},
|
||||
]
|
||||
|
|
@ -109,6 +106,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
user,
|
||||
state,
|
||||
)
|
||||
const columnVisibility = await readCampaignColumnVisibility('demandas')
|
||||
|
||||
const hrefForStatus = (status?: CampaignDemandStatus) =>
|
||||
buildDemandListHref({ ...state, status }, 1)
|
||||
|
|
@ -150,7 +148,12 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
<CampaignListResults>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<CampaignTable columns={demandColumns} rows={rows} rowKey={(row) => row.id} />
|
||||
<CampaignTable
|
||||
columns={demandColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="demanda"
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@ import {
|
|||
CampaignListResults,
|
||||
CampaignTransitionAnchor,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellOption,
|
||||
|
|
@ -34,6 +30,7 @@ import {
|
|||
import { cn } from '@/lib/utils'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
loadStateDeputyListPageData,
|
||||
|
|
@ -49,6 +46,7 @@ import {
|
|||
formatStateDeputyListSortSummary,
|
||||
resolveStateDeputyListSort,
|
||||
resolveStateDeputyListUrl,
|
||||
stateDeputyListSortLabels,
|
||||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
|
|
@ -91,6 +89,7 @@ const stateDeputyColumns = (
|
|||
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
label: stateDeputyListSortLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<StateDeputySortableHead state={state} sortKey="name">
|
||||
|
|
@ -108,6 +107,7 @@ const stateDeputyColumns = (
|
|||
},
|
||||
{
|
||||
id: 'party',
|
||||
label: stateDeputyListSortLabels.party,
|
||||
head: (
|
||||
<StateDeputySortableHead
|
||||
state={state}
|
||||
|
|
@ -123,13 +123,13 @@ const stateDeputyColumns = (
|
|||
},
|
||||
{
|
||||
id: 'municipalities',
|
||||
head: <CampaignTableHead>Municípios</CampaignTableHead>,
|
||||
label: 'Municípios',
|
||||
cellClassName: 'tabular-nums',
|
||||
cell: (row) => row.municipalityCount,
|
||||
},
|
||||
{
|
||||
id: 'leaderships',
|
||||
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
|
||||
label: 'Lideranças',
|
||||
cellClassName: 'max-w-72 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<LeadershipStateDeputyRelationCell
|
||||
|
|
@ -164,6 +164,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
||||
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
|
||||
const { sort, dir } = resolveStateDeputyListSort(state)
|
||||
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
|
||||
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))
|
||||
|
|
@ -214,6 +215,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
<CampaignTable
|
||||
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
empty={<StateDeputyListEmptyState state={state} />}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { MAX_LEADERSHIP_MUNICIPALITIES, isLeadershipSector } from '@/lib/schemas
|
|||
import { cn } from '@/lib/utils'
|
||||
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
|
||||
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
|
||||
|
|
@ -125,6 +126,7 @@ const leadershipColumns = ({
|
|||
}): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Nome',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<LeadershipSortableHead state={state} sortKey="name">
|
||||
|
|
@ -142,13 +144,13 @@ const leadershipColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'email',
|
||||
head: <CampaignTableHead>E-mail</CampaignTableHead>,
|
||||
label: 'E-mail',
|
||||
cellClassName: 'max-w-56',
|
||||
cell: (row) => <CampaignCopyableCell value={row.email} label="E-mail" />,
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
head: <CampaignTableHead>Celular</CampaignTableHead>,
|
||||
label: 'Celular',
|
||||
cell: (row) => (
|
||||
<CampaignCopyableCell
|
||||
value={row.phone}
|
||||
|
|
@ -160,6 +162,7 @@ const leadershipColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'supportStatus',
|
||||
label: 'Status',
|
||||
head: (
|
||||
<LeadershipSortableHead state={state} sortKey="supportStatus" filterParam="supportStatus">
|
||||
Status
|
||||
|
|
@ -171,6 +174,7 @@ const leadershipColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'sector',
|
||||
label: 'Setor',
|
||||
head: (
|
||||
<LeadershipSortableHead state={state} sortKey="sector" filterParam="sector">
|
||||
Setor
|
||||
|
|
@ -183,6 +187,7 @@ const leadershipColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'municipalities',
|
||||
label: 'Municípios',
|
||||
head: (
|
||||
<LeadershipFilterHead
|
||||
state={state}
|
||||
|
|
@ -211,13 +216,13 @@ const leadershipColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'organizations',
|
||||
head: <CampaignTableHead>Organizações</CampaignTableHead>,
|
||||
label: 'Organizações',
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (row) => row.organizationNames.join(', ') || '—',
|
||||
},
|
||||
{
|
||||
id: 'stateDeputies',
|
||||
head: <CampaignTableHead>Dobradinhas</CampaignTableHead>,
|
||||
label: 'Dobradinhas',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<LeadershipStateDeputyRelationCell
|
||||
|
|
@ -237,6 +242,7 @@ const leadershipColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'appAccess',
|
||||
label: 'Acesso ao app',
|
||||
head: (
|
||||
<LeadershipFilterHead state={state} filterParam="access">
|
||||
Acesso ao app
|
||||
|
|
@ -268,6 +274,7 @@ const leadershipColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
head: (
|
||||
<CampaignTableHead align="right">
|
||||
<span className="sr-only">Ações</span>
|
||||
|
|
@ -357,6 +364,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
|
||||
const { sort, dir } = resolveLeadershipListSort(state)
|
||||
const sortSummary = formatLeadershipListSortSummary(sort, dir)
|
||||
const columnVisibility = await readCampaignColumnVisibility('liderancas')
|
||||
const columns = leadershipColumns({
|
||||
state,
|
||||
stateDeputyOptions: stateDeputyOptions.map((option) => ({
|
||||
|
|
@ -402,6 +410,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
<CampaignTable
|
||||
caption={`${sortSummary}. Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.`}
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
empty={<LeadershipListEmptyState state={state} />}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
isCampaignUnrestricted,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { getCampaignScopeLabel } from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
buildMunicipalityListVisitHref,
|
||||
|
|
@ -64,6 +65,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
const listVisitLabel = buildMunicipalityListVisitLabel(state)
|
||||
const columnVisibility = await readCampaignColumnVisibility('municipios')
|
||||
|
||||
// One read covers both needs: the listed municipalities' avatars and the
|
||||
// (cross-filtered) advisor column-filter labels.
|
||||
|
|
@ -129,6 +131,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
columnFilterOptions={columnFilterOptions}
|
||||
signalFormAction={createMunicipalityListSignalFormAction}
|
||||
state={state}
|
||||
columnVisibility={columnVisibility}
|
||||
/>
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,14 @@ import {
|
|||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { organizationKindLabels } from '@/lib/schemas/organization'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import {
|
||||
buildOrganizationListHref,
|
||||
loadOrganizationListPageData,
|
||||
|
|
@ -36,8 +33,8 @@ type OrganizationsPageProps = {
|
|||
const organizationColumns: Array<CampaignTableColumn<OrganizationRowViewModel>> = [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Nome',
|
||||
mandatory: true,
|
||||
head: <CampaignTableHead>Nome</CampaignTableHead>,
|
||||
cell: (row) => (
|
||||
<Link
|
||||
href={`/campanha/organizacoes/${row.slug}`}
|
||||
|
|
@ -49,18 +46,18 @@ const organizationColumns: Array<CampaignTableColumn<OrganizationRowViewModel>>
|
|||
},
|
||||
{
|
||||
id: 'kind',
|
||||
head: <CampaignTableHead>Tipo</CampaignTableHead>,
|
||||
label: 'Tipo',
|
||||
cell: (row) => <Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'municipalities',
|
||||
head: <CampaignTableHead>Municípios de atuação</CampaignTableHead>,
|
||||
label: 'Municípios de atuação',
|
||||
cellClassName: 'max-w-64 whitespace-normal text-muted-foreground',
|
||||
cell: (row) => row.municipalityNames.join(', ') || '—',
|
||||
},
|
||||
{
|
||||
id: 'leaderships',
|
||||
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
|
||||
label: 'Lideranças',
|
||||
cellClassName: 'tabular-nums',
|
||||
cell: (row) => row.leadershipCount,
|
||||
},
|
||||
|
|
@ -74,6 +71,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||
|
||||
const state = parseOrganizationListParams(rawSearchParams)
|
||||
const { rows, totalDocs, totalPages } = await loadOrganizationListPageData(payload, user, state)
|
||||
const columnVisibility = await readCampaignColumnVisibility('organizacoes')
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
|
|
@ -104,7 +102,12 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||
<CampaignListResults>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<CampaignTable columns={organizationColumns} rows={rows} rowKey={(row) => row.id} />
|
||||
<CampaignTable
|
||||
columns={organizationColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="organização"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { isCampaignLeader } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
|
||||
import { resolveTerritoryListSort, resolveTerritoryListUrl } from '@/utilities/territoryListUrl'
|
||||
import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territoryOverview'
|
||||
|
|
@ -31,6 +32,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
|
|||
if (isCampaignLeader(user)) redirect('/campanha')
|
||||
|
||||
const allRows = await loadTerritoryOverview(payload, user)
|
||||
const columnVisibility = await readCampaignColumnVisibility('territorios')
|
||||
const { state } = resolvedUrl
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
const rows = sortTerritoryRows(filterTerritoryRows(allRows, state), sort, dir)
|
||||
|
|
@ -52,7 +54,12 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
|
|||
<CampaignListPendingBoundary>
|
||||
<TerritoryFilters state={state} regionOptions={regionOptions} />
|
||||
<CampaignListResults>
|
||||
<TerritoryList rows={rows} state={state} regionOptions={regionOptions} />
|
||||
<TerritoryList
|
||||
rows={rows}
|
||||
state={state}
|
||||
regionOptions={regionOptions}
|
||||
columnVisibility={columnVisibility}
|
||||
/>
|
||||
<CampaignListFooter
|
||||
totalDocs={rows.length}
|
||||
singular="território encontrado"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import {
|
||||
resolveVisibleColumns,
|
||||
type CampaignColumnVisibility,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import {
|
||||
formatElectionNumber,
|
||||
formatPlacementOrdinal,
|
||||
|
|
@ -45,6 +49,7 @@ import {
|
|||
municipalityPriorityLabels,
|
||||
territorialClassBadgeVariant,
|
||||
territorialClassLabels,
|
||||
type MunicipalityListColumnId,
|
||||
} from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
clearMunicipalityListFilters,
|
||||
|
|
@ -53,6 +58,7 @@ import {
|
|||
import {
|
||||
buildMunicipalityListHref,
|
||||
formatMunicipalityListSortSummary,
|
||||
municipalityColumnLabels,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
|
|
@ -98,6 +104,8 @@ export type MunicipalityListProps = {
|
|||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
state: MunicipalityListState
|
||||
/** B17 — optional only because the unit tests render this list without a cookie. */
|
||||
columnVisibility?: CampaignColumnVisibility
|
||||
}
|
||||
|
||||
const VotePositionReadout = ({
|
||||
|
|
@ -253,9 +261,12 @@ const municipalityListColumns = ({
|
|||
advisorNamesById,
|
||||
advisorOptions,
|
||||
signalFormAction,
|
||||
}: MunicipalityListProps): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
|
||||
}: MunicipalityListProps): Array<
|
||||
CampaignTableColumn<MunicipalityListViewModel> & { id: MunicipalityListColumnId }
|
||||
> => [
|
||||
{
|
||||
id: 'name',
|
||||
label: municipalityColumnLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
|
|
@ -287,6 +298,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'region',
|
||||
label: municipalityColumnLabels.region,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -303,6 +315,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'kind',
|
||||
label: municipalityColumnLabels.kind,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -317,6 +330,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'votos',
|
||||
label: municipalityColumnLabels.votos,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -339,6 +353,7 @@ const municipalityListColumns = ({
|
|||
// Sits right after "2022": both read the same TSE artifact, and the
|
||||
// class is the one-word summary of the votes beside it.
|
||||
id: 'classe',
|
||||
label: municipalityColumnLabels.classe,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -360,12 +375,13 @@ const municipalityListColumns = ({
|
|||
{
|
||||
// Right after "Classe": the pair reads diagnosis then decision.
|
||||
id: 'level',
|
||||
label: municipalityColumnLabels.level,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="nivel"
|
||||
filterParam="level"
|
||||
description={municipalityColumnDescriptions.nivel}
|
||||
description={municipalityColumnDescriptions.level}
|
||||
>
|
||||
Nível
|
||||
</MunicipalitySortableHead>
|
||||
|
|
@ -398,6 +414,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'advisors',
|
||||
label: municipalityColumnLabels.advisors,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -436,6 +453,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'trend',
|
||||
label: municipalityColumnLabels.trend,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -458,6 +476,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'expectedVotes',
|
||||
label: municipalityColumnLabels.expectedVotes,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -483,6 +502,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'lastSignal',
|
||||
label: municipalityColumnLabels.lastSignal,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -507,6 +527,7 @@ const municipalityListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'goalCoverage',
|
||||
label: municipalityColumnLabels.goalCoverage,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -522,10 +543,13 @@ const municipalityListColumns = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)
|
||||
] satisfies Array<
|
||||
CampaignTableColumn<MunicipalityListViewModel> & { id: MunicipalityListColumnId }
|
||||
>)
|
||||
: ([
|
||||
{
|
||||
id: 'lastUpdateAt',
|
||||
label: municipalityColumnLabels.lastUpdateAt,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
|
|
@ -540,7 +564,9 @@ const municipalityListColumns = ({
|
|||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização',
|
||||
},
|
||||
] satisfies Array<CampaignTableColumn<MunicipalityListViewModel>>)),
|
||||
] satisfies Array<
|
||||
CampaignTableColumn<MunicipalityListViewModel> & { id: MunicipalityListColumnId }
|
||||
>)),
|
||||
]
|
||||
|
||||
export const MunicipalityList = (props: MunicipalityListProps) => {
|
||||
|
|
@ -549,6 +575,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
|
||||
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
|
||||
const columns = municipalityListColumns(props)
|
||||
// Asks the same function the table asks, so a column that becomes
|
||||
// `mandatory` cannot be on screen while the caption denies it.
|
||||
const showsVoteColumn = resolveVisibleColumns(
|
||||
columns,
|
||||
props.columnVisibility?.hiddenColumnIds,
|
||||
).some((column) => column.id === 'votos')
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -726,10 +758,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
caption={
|
||||
<>
|
||||
{sortSummary}. Coluna 2022: {municipalityColumnDescriptions.votos}
|
||||
{sortSummary}
|
||||
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
columnVisibility={props.columnVisibility}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||||
import { territoryAnchorId } from '@/lib/territoryAnchor'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
|
|
@ -54,10 +55,12 @@ export const TerritoryList = ({
|
|||
rows,
|
||||
state,
|
||||
regionOptions,
|
||||
columnVisibility,
|
||||
}: {
|
||||
rows: TerritoryOverviewRow[]
|
||||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
columnVisibility: CampaignColumnVisibility
|
||||
}) => {
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
const sortSummary = formatTerritoryListSortSummary(sort, dir)
|
||||
|
|
@ -73,6 +76,7 @@ export const TerritoryList = ({
|
|||
containerClassName="overflow-x-auto"
|
||||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
columns={territoryListColumns({ state, regionOptions })}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={flattenTerritoryRows(rows)}
|
||||
rowKey={(row) =>
|
||||
row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from '@/utilities/municipalityLabels'
|
||||
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
|
||||
import { territoryColumnDescriptions } from '@/utilities/territoryListLabels'
|
||||
import type { TerritoryListState } from '@/utilities/territoryListUrl'
|
||||
import { territoryListSortLabels, type TerritoryListState } from '@/utilities/territoryListUrl'
|
||||
import type { TerritoryTableRow } from '@/utilities/territoryOverview'
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('pt-BR', {
|
||||
|
|
@ -146,7 +146,7 @@ export const territoryListColumns = ({
|
|||
{
|
||||
id: 'region',
|
||||
mandatory: true,
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.region,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -173,7 +173,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'municipalities',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.municipalities,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -187,7 +187,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'votes2022',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.votes2022,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -201,7 +201,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'pct',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.pct,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -216,7 +216,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'validVotes2022',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.validVotes2022,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -230,7 +230,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'estimate2026',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.estimate2026,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -245,7 +245,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'cobertura',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.cobertura,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -271,7 +271,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'captura',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.captura,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -286,7 +286,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'classe',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.classe,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
@ -302,7 +302,7 @@ export const territoryListColumns = ({
|
|||
},
|
||||
{
|
||||
id: 'coverage',
|
||||
defaultVisible: true,
|
||||
label: territoryListSortLabels.coverage,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
|
|
|
|||
211
src/components/campaign/shared/CampaignColumnPicker.tsx
Normal file
211
src/components/campaign/shared/CampaignColumnPicker.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
'use client'
|
||||
|
||||
import { Columns3Icon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import {
|
||||
CAMPAIGN_COLUMNS_COOKIE,
|
||||
CAMPAIGN_COLUMNS_COOKIE_MAX_AGE,
|
||||
CAMPAIGN_COLUMNS_COOKIE_PATH,
|
||||
parseCampaignHiddenColumns,
|
||||
serializeCampaignHiddenColumns,
|
||||
toggleHiddenColumn,
|
||||
type CampaignListId,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
|
||||
type CampaignColumnPickerColumn = {
|
||||
id: string
|
||||
label: string
|
||||
mandatory?: boolean
|
||||
}
|
||||
|
||||
type CampaignColumnPickerProps = {
|
||||
listId: CampaignListId
|
||||
/** Every column of the table, hidden ones included — this is the menu. */
|
||||
columns: readonly CampaignColumnPickerColumn[]
|
||||
/** Server truth: what this device has hidden on this list. */
|
||||
hiddenColumnIds: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing the menu is what commits — an editing session is one navigation, not
|
||||
* one per checkbox. A 400 ms debounce only ever batched clicks faster than
|
||||
* reading the label between them, and each miss re-runs the whole route: on
|
||||
* `/campanha/liderancas` that is the ~229 ms / 371-statement render ledgered as
|
||||
* P1. This timer is the safety net for a menu left open, not the commit path.
|
||||
* It reads a ref, never the state of the render that scheduled it — the
|
||||
* `setTimeout`-captures-stale-state bug B33+ pinned across the list filters.
|
||||
*/
|
||||
const COMMIT_IDLE_MS = 3_000
|
||||
|
||||
const readCookie = () =>
|
||||
parseCampaignHiddenColumns(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((entry) => entry.startsWith(`${CAMPAIGN_COLUMNS_COOKIE}=`))
|
||||
?.slice(CAMPAIGN_COLUMNS_COOKIE.length + 1),
|
||||
)
|
||||
|
||||
const writeCookie = (listId: CampaignListId, hiddenColumnIds: readonly string[]) => {
|
||||
// Re-read before writing: one cookie carries every list, and another tab may
|
||||
// have changed a different one since this page rendered.
|
||||
const next = { ...readCookie(), [listId]: [...hiddenColumnIds] }
|
||||
|
||||
document.cookie = [
|
||||
`${CAMPAIGN_COLUMNS_COOKIE}=${serializeCampaignHiddenColumns(next)}`,
|
||||
`path=${CAMPAIGN_COLUMNS_COOKIE_PATH}`,
|
||||
`max-age=${CAMPAIGN_COLUMNS_COOKIE_MAX_AGE}`,
|
||||
'samesite=lax',
|
||||
// Matches `campaignCookieOptions`; keyed off the protocol rather than
|
||||
// NODE_ENV because `secure` on plain http silently drops the write.
|
||||
...(window.location.protocol === 'https:' ? ['secure'] : []),
|
||||
].join('; ')
|
||||
}
|
||||
|
||||
export const CampaignColumnPicker = ({
|
||||
listId,
|
||||
columns,
|
||||
hiddenColumnIds,
|
||||
}: CampaignColumnPickerProps) => {
|
||||
const router = useRouter()
|
||||
// Only the transition — the busy state belongs to `CampaignListResults`,
|
||||
// which wraps this table and already dims and marks itself `aria-busy`.
|
||||
const { startTransition } = useCampaignListTransition()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [hidden, setHidden] = useState<string[]>(() => [...hiddenColumnIds])
|
||||
const [serverIds, setServerIds] = useState<readonly string[]>(hiddenColumnIds)
|
||||
const latestHidden = useRef(hidden)
|
||||
const commitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// The server is the source of truth: once its payload catches up, it wins.
|
||||
// But never over an edit that has not been written yet — the payload of
|
||||
// toggle A lands while B is still in the debounce window, and adopting it
|
||||
// there would roll B back and then commit the rolled-back set, losing it.
|
||||
// Same policy as `useCampaignListFilterNavigation`, where a pending debounce
|
||||
// outranks the URL; `sameIdSet` is the shared "did the server really change".
|
||||
if (!sameIdSet(serverIds, hiddenColumnIds) && commitTimer.current === null) {
|
||||
setServerIds(hiddenColumnIds)
|
||||
setHidden([...hiddenColumnIds])
|
||||
latestHidden.current = [...hiddenColumnIds]
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (commitTimer.current) clearTimeout(commitTimer.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const commit = () => {
|
||||
if (commitTimer.current) {
|
||||
clearTimeout(commitTimer.current)
|
||||
commitTimer.current = null
|
||||
}
|
||||
writeCookie(listId, latestHidden.current)
|
||||
startTransition(() => {
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const apply = (next: string[]) => {
|
||||
// Advance the ref synchronously: two toggles batched into one tick must not
|
||||
// drop each other, and the debounced commit reads the ref.
|
||||
latestHidden.current = next
|
||||
setHidden(next)
|
||||
|
||||
if (commitTimer.current) clearTimeout(commitTimer.current)
|
||||
commitTimer.current = setTimeout(commit, COMMIT_IDLE_MS)
|
||||
}
|
||||
|
||||
const toggle = (columnId: string, visible: boolean) => {
|
||||
apply(toggleHiddenColumn(latestHidden.current, columnId, visible))
|
||||
}
|
||||
|
||||
const hiddenSet = new Set(hidden)
|
||||
const hiddenCount = columns.filter(
|
||||
(column) => !column.mandatory && hiddenSet.has(column.id),
|
||||
).length
|
||||
const headingId = `${listId}-column-picker`
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
// Closing is the commit: the whole session lands in one refresh.
|
||||
if (!next && commitTimer.current) commit()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
aria-label={
|
||||
hiddenCount
|
||||
? `Mostrar ou ocultar colunas. ${hiddenCount} oculta${hiddenCount > 1 ? 's' : ''}`
|
||||
: 'Mostrar ou ocultar colunas'
|
||||
}
|
||||
>
|
||||
<Columns3Icon aria-hidden="true" />
|
||||
Colunas
|
||||
{hiddenCount ? (
|
||||
<span className="text-muted-foreground tabular-nums">({hiddenCount})</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Radix gives the content `role="dialog"` and no name — same reason
|
||||
`CampaignCellEditOverlay` labels its own. */}
|
||||
<PopoverContent align="end" aria-label="Mostrar colunas" className="w-64 p-2">
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<p id={headingId} className="text-xs font-medium text-muted-foreground">
|
||||
Mostrar colunas
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={hiddenCount === 0}
|
||||
onClick={() => apply([])}
|
||||
>
|
||||
Restaurar todas
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={headingId}
|
||||
className="flex max-h-80 flex-col gap-0.5 overflow-y-auto"
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const checkboxId = `${listId}-column-${column.id}`
|
||||
|
||||
return (
|
||||
<label
|
||||
key={column.id}
|
||||
htmlFor={checkboxId}
|
||||
className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted has-disabled:hover:bg-transparent"
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={column.mandatory || !hiddenSet.has(column.id)}
|
||||
disabled={column.mandatory}
|
||||
onCheckedChange={(next) => toggle(column.id, next === true)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 whitespace-normal">{column.label}</span>
|
||||
{column.mandatory ? (
|
||||
<span className="text-xs text-muted-foreground">sempre visível</span>
|
||||
) : null}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignCellTooltip } from '@/components/campaign/shared/CampaignCellTooltip'
|
||||
import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColumnPicker'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -11,6 +12,10 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import {
|
||||
resolveVisibleColumns,
|
||||
type CampaignColumnVisibility,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import {
|
||||
campaignHoverExplanationClassName,
|
||||
campaignHoverTooltipAlign,
|
||||
|
|
@ -24,14 +29,25 @@ import { cn } from '@/lib/utils'
|
|||
* a definition instead of a new table.
|
||||
*/
|
||||
export type CampaignTableColumn<Row> = {
|
||||
/** Stable identifier — the future column-picker key (B17). */
|
||||
/** Stable identifier — the column-picker key (B17). */
|
||||
id: string
|
||||
/**
|
||||
* Full `<th>` node. Plain labels use `<CampaignTableHead>`; rich columns
|
||||
* render their own head island (e.g. `MunicipalitySortableHead`), which owns
|
||||
* `aria-sort` and the filter popover.
|
||||
* Short pt-BR name of the column, and the default header copy. `head` is an
|
||||
* opaque `ReactNode` (often a client island), so the picker — which only
|
||||
* receives serializable data — needs the name spelled out here.
|
||||
*
|
||||
* It normally IS the header copy, but it does not have to be: a header can
|
||||
* afford to be terse because it sits above its own data (municípios' "2022"),
|
||||
* while the same word alone in a menu of column names says nothing.
|
||||
*/
|
||||
head: ReactNode
|
||||
label: string
|
||||
/**
|
||||
* Full `<th>` node, defaulting to `<CampaignTableHead>{label}</CampaignTableHead>`.
|
||||
* Declare it only when the header is more than its name: an alignment, a
|
||||
* B22 `description`, or a head island of its own (`MunicipalitySortableHead`)
|
||||
* owning `aria-sort` and the filter popover.
|
||||
*/
|
||||
head?: ReactNode
|
||||
cell: (row: Row) => ReactNode
|
||||
/**
|
||||
* Extra reading for the cell content, shown on hover/focus/tap. Return
|
||||
|
|
@ -46,9 +62,8 @@ export type CampaignTableColumn<Row> = {
|
|||
*/
|
||||
cellTooltip?: (row: Row) => ReactNode
|
||||
cellClassName?: string | ((row: Row) => string | undefined)
|
||||
/** B17 seams — a mandatory column is not hideable; hidden-by-default starts unchecked. */
|
||||
/** B17 — a mandatory column is listed in the picker but cannot be unchecked. */
|
||||
mandatory?: boolean
|
||||
defaultVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -124,6 +139,12 @@ export const CampaignTableHead = ({
|
|||
|
||||
type CampaignTableProps<Row> = {
|
||||
columns: Array<CampaignTableColumn<Row>>
|
||||
/**
|
||||
* B17 — passing this turns the column picker on. The caller reads the cookie
|
||||
* (`readCampaignColumnVisibility`) and hands the result over whole: hidden
|
||||
* columns are never rendered, so they cost nothing in the RSC payload either.
|
||||
*/
|
||||
columnVisibility?: CampaignColumnVisibility
|
||||
rows: readonly Row[]
|
||||
rowKey: (row: Row) => string | number
|
||||
/** Optional DOM id per row (e.g. hash targets on `/campanha/territorios`). */
|
||||
|
|
@ -143,6 +164,7 @@ type CampaignTableProps<Row> = {
|
|||
|
||||
export const CampaignTable = <Row,>({
|
||||
columns,
|
||||
columnVisibility,
|
||||
rows,
|
||||
rowKey,
|
||||
rowId,
|
||||
|
|
@ -152,55 +174,78 @@ export const CampaignTable = <Row,>({
|
|||
containerClassName,
|
||||
headerClassName,
|
||||
rowClassName,
|
||||
}: CampaignTableProps<Row>) => (
|
||||
<div className={cn('overflow-hidden rounded-xl border', className)}>
|
||||
<Table containerClassName={containerClassName}>
|
||||
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}
|
||||
<TableHeader className={headerClassName}>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<Fragment key={column.id}>{column.head}</Fragment>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && empty ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="whitespace-normal">
|
||||
{empty}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
id={rowId?.(row)}
|
||||
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const cell = column.cell(row)
|
||||
const tooltip = column.cellTooltip?.(row)
|
||||
}: CampaignTableProps<Row>) => {
|
||||
const visibleColumns = resolveVisibleColumns(columns, columnVisibility?.hiddenColumnIds)
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={
|
||||
typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row)
|
||||
: column.cellClassName
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<CampaignCellTooltip content={tooltip}>{cell}</CampaignCellTooltip>
|
||||
) : (
|
||||
cell
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
{columnVisibility ? (
|
||||
// Gated at `md:` because municípios and apoiadores hide this table
|
||||
// below that width (`className="hidden md:block"`) and show curated
|
||||
// cards instead — a picker over a table nobody can see is noise. The
|
||||
// cost is that the five surfaces which DO scroll this table on a phone
|
||||
// have no picker there; giving them one needs a per-caller seam, not a
|
||||
// breakpoint (registered as a fill-in in the B17 plan).
|
||||
<div className="hidden justify-end md:flex">
|
||||
<CampaignColumnPicker
|
||||
listId={columnVisibility.listId}
|
||||
columns={columns.map(({ id, label, mandatory }) => ({ id, label, mandatory }))}
|
||||
hiddenColumnIds={columnVisibility.hiddenColumnIds}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn('overflow-hidden rounded-xl border', className)}>
|
||||
<Table containerClassName={containerClassName}>
|
||||
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}
|
||||
<TableHeader className={headerClassName}>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column) => (
|
||||
<Fragment key={column.id}>
|
||||
{column.head ?? <CampaignTableHead>{column.label}</CampaignTableHead>}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && empty ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={visibleColumns.length} className="whitespace-normal">
|
||||
{empty}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
</TableRow>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
id={rowId?.(row)}
|
||||
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
const cell = column.cell(row)
|
||||
const tooltip = column.cellTooltip?.(row)
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={
|
||||
typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row)
|
||||
: column.cellClassName
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<CampaignCellTooltip content={tooltip}>{cell}</CampaignCellTooltip>
|
||||
) : (
|
||||
cell
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
import { PhoneIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||||
import { formatBrazilianPhoneInput } from '@/lib/phone'
|
||||
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
|
||||
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
|
||||
|
||||
export type SupporterListProps = {
|
||||
supporters: SupporterListItemViewModel[]
|
||||
columnVisibility: CampaignColumnVisibility
|
||||
}
|
||||
|
||||
const voteIntentionBadgeVariant = (
|
||||
|
|
@ -83,8 +81,8 @@ const SupporterCard = ({ supporter }: { supporter: SupporterListItemViewModel })
|
|||
const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> = [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Nome',
|
||||
mandatory: true,
|
||||
head: <CampaignTableHead>Nome</CampaignTableHead>,
|
||||
cellClassName: 'max-w-52 whitespace-normal',
|
||||
cell: (supporter) => (
|
||||
<Link
|
||||
|
|
@ -97,12 +95,12 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
|
|||
},
|
||||
{
|
||||
id: 'city',
|
||||
head: <CampaignTableHead>Cidade</CampaignTableHead>,
|
||||
label: 'Cidade',
|
||||
cell: (supporter) => supporter.city ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'municipality',
|
||||
head: <CampaignTableHead>Município</CampaignTableHead>,
|
||||
label: 'Município',
|
||||
cellClassName: 'max-w-48 whitespace-normal',
|
||||
cell: (supporter) =>
|
||||
supporter.municipalityName ? (
|
||||
|
|
@ -118,7 +116,7 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
|
|||
},
|
||||
{
|
||||
id: 'voteIntention',
|
||||
head: <CampaignTableHead>Intenção</CampaignTableHead>,
|
||||
label: 'Intenção',
|
||||
cell: (supporter) => {
|
||||
const voteIntentionLabel = supporter.voteIntention
|
||||
? supporterVoteIntentionLabels[supporter.voteIntention]
|
||||
|
|
@ -134,13 +132,13 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
|
|||
},
|
||||
{
|
||||
id: 'phone',
|
||||
head: <CampaignTableHead>Telefone</CampaignTableHead>,
|
||||
label: 'Telefone',
|
||||
cellClassName: 'tabular-nums',
|
||||
cell: (supporter) => (supporter.phone ? formatBrazilianPhoneInput(supporter.phone) : '—'),
|
||||
},
|
||||
]
|
||||
|
||||
export const SupporterList = ({ supporters }: SupporterListProps) => (
|
||||
export const SupporterList = ({ supporters, columnVisibility }: SupporterListProps) => (
|
||||
<>
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
{supporters.map((supporter) => (
|
||||
|
|
@ -151,6 +149,7 @@ export const SupporterList = ({ supporters }: SupporterListProps) => (
|
|||
<CampaignTable
|
||||
className="hidden md:block"
|
||||
columns={supporterColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={supporters}
|
||||
rowKey={(supporter) => supporter.id}
|
||||
/>
|
||||
|
|
|
|||
124
src/lib/campaignColumnVisibility.ts
Normal file
124
src/lib/campaignColumnVisibility.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* B17 — which columns of a `CampaignTable` the actor chose to hide.
|
||||
*
|
||||
* Pure and client-safe: the picker (client) serializes, the list surfaces
|
||||
* (RSC) parse and filter. Persistence is a cookie rather than `localStorage`
|
||||
* because the column array never crosses the server/client boundary
|
||||
* (`CampaignTableColumn.cell` is a function), so the server is the only place
|
||||
* that can drop a column — and a cookie is the only per-device preference the
|
||||
* server can read on the first paint, with no flash of the full table.
|
||||
*/
|
||||
|
||||
export const CAMPAIGN_COLUMNS_COOKIE = 'campaign_columns'
|
||||
/** Scoped like the campaign session cookie: the picker only exists under /campanha. */
|
||||
export const CAMPAIGN_COLUMNS_COOKIE_PATH = '/campanha'
|
||||
export const CAMPAIGN_COLUMNS_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
|
||||
|
||||
/** A malformed or oversized cookie shows the full table instead of guessing. */
|
||||
const MAX_COOKIE_LENGTH = 2048
|
||||
const MAX_HIDDEN_PER_LIST = 40
|
||||
|
||||
const CAMPAIGN_LIST_IDS = [
|
||||
'municipios',
|
||||
'liderancas',
|
||||
'dobradinhas',
|
||||
'organizacoes',
|
||||
'demandas',
|
||||
'apoiadores',
|
||||
'territorios',
|
||||
] as const
|
||||
|
||||
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]
|
||||
|
||||
export type CampaignHiddenColumns = Partial<Record<CampaignListId, string[]>>
|
||||
|
||||
/**
|
||||
* What one list surface needs to run the picker, as ONE value. The id is what
|
||||
* the cookie is keyed by and what the picker writes back, so the reader and
|
||||
* the table must agree on it — as two props they could disagree between two
|
||||
* perfectly valid ids, and the only symptom would be a picker that does nothing.
|
||||
*/
|
||||
export type CampaignColumnVisibility = {
|
||||
listId: CampaignListId
|
||||
hiddenColumnIds: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* `listId:colA~colB|otherList:colC`. Every character here is legal in a
|
||||
* cookie value (RFC 6265 forbids comma, semicolon, whitespace, quotes and
|
||||
* backslash), so nothing is percent-encoded — which matters because
|
||||
* `document.cookie` and Next's `cookies()` disagree about decoding.
|
||||
*/
|
||||
const LIST_SEPARATOR = '|'
|
||||
const LIST_ID_SEPARATOR = ':'
|
||||
const COLUMN_SEPARATOR = '~'
|
||||
const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
const isCampaignListId = (value: string): value is CampaignListId =>
|
||||
CAMPAIGN_LIST_IDS.some((listId) => listId === value)
|
||||
|
||||
export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHiddenColumns => {
|
||||
if (!raw || raw.length > MAX_COOKIE_LENGTH) return {}
|
||||
|
||||
const parsed: CampaignHiddenColumns = {}
|
||||
|
||||
for (const entry of raw.split(LIST_SEPARATOR)) {
|
||||
const separatorIndex = entry.indexOf(LIST_ID_SEPARATOR)
|
||||
if (separatorIndex <= 0) continue
|
||||
|
||||
const listId = entry.slice(0, separatorIndex)
|
||||
if (!isCampaignListId(listId)) continue
|
||||
|
||||
const columnIds = [
|
||||
...new Set(
|
||||
entry
|
||||
.slice(separatorIndex + 1)
|
||||
.split(COLUMN_SEPARATOR)
|
||||
.filter((columnId) => COLUMN_ID_PATTERN.test(columnId)),
|
||||
),
|
||||
].slice(0, MAX_HIDDEN_PER_LIST)
|
||||
|
||||
if (columnIds.length) parsed[listId] = columnIds
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export const serializeCampaignHiddenColumns = (value: CampaignHiddenColumns): string =>
|
||||
CAMPAIGN_LIST_IDS.flatMap((listId) => {
|
||||
const columnIds = (value[listId] ?? []).filter((columnId) => COLUMN_ID_PATTERN.test(columnId))
|
||||
if (!columnIds.length) return []
|
||||
return [`${listId}${LIST_ID_SEPARATOR}${columnIds.join(COLUMN_SEPARATOR)}`]
|
||||
}).join(LIST_SEPARATOR)
|
||||
|
||||
/**
|
||||
* Records one toggle. Hiding is stored (never the visible set), so a column
|
||||
* shipped by a later item — E9's derived signals, E14's level — is visible to
|
||||
* everyone who never touched the picker, which is the contract those items
|
||||
* assumed.
|
||||
*/
|
||||
export const toggleHiddenColumn = (
|
||||
hiddenColumnIds: readonly string[],
|
||||
columnId: string,
|
||||
visible: boolean,
|
||||
): string[] => {
|
||||
const without = hiddenColumnIds.filter((id) => id !== columnId)
|
||||
return visible ? without : [...without, columnId]
|
||||
}
|
||||
|
||||
type HideableColumn = { id: string; mandatory?: boolean }
|
||||
|
||||
/**
|
||||
* A `mandatory` column is never dropped, whatever the cookie says — an actor
|
||||
* who lands with a stale cookie must not end up with a table he cannot read
|
||||
* (or, on `/campanha/municipios`, without the only linked name in the row).
|
||||
*/
|
||||
export const resolveVisibleColumns = <Column extends HideableColumn>(
|
||||
columns: readonly Column[],
|
||||
hiddenColumnIds: readonly string[] | undefined,
|
||||
): Column[] => {
|
||||
if (!hiddenColumnIds?.length) return [...columns]
|
||||
|
||||
const hidden = new Set(hiddenColumnIds)
|
||||
return columns.filter((column) => column.mandatory || !hidden.has(column.id))
|
||||
}
|
||||
|
|
@ -4,7 +4,10 @@
|
|||
* (navigation/RSC refresh) apart from a re-render carrying the same
|
||||
* pre-edit content (which must not clobber in-flight optimistic state).
|
||||
*/
|
||||
export const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => {
|
||||
export const sameIdSet = <Id extends string | number>(
|
||||
left: readonly Id[],
|
||||
right: readonly Id[],
|
||||
): boolean => {
|
||||
if (left.length !== right.length) return false
|
||||
const rightSet = new Set(right)
|
||||
return left.every((id) => rightSet.has(id))
|
||||
|
|
|
|||
24
src/utilities/campaignColumnVisibilityCookie.ts
Normal file
24
src/utilities/campaignColumnVisibilityCookie.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import 'server-only'
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
import {
|
||||
CAMPAIGN_COLUMNS_COOKIE,
|
||||
parseCampaignHiddenColumns,
|
||||
type CampaignColumnVisibility,
|
||||
type CampaignListId,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
|
||||
/**
|
||||
* B17 — what this device hid on one list, ready to hand to `<CampaignTable>`.
|
||||
* Called by whichever server component renders the table; `/campanha` is
|
||||
* already dynamic (auth), so reading a cookie costs no caching.
|
||||
*/
|
||||
export const readCampaignColumnVisibility = async (
|
||||
listId: CampaignListId,
|
||||
): Promise<CampaignColumnVisibility> => {
|
||||
const cookieStore = await cookies()
|
||||
const raw = cookieStore.get(CAMPAIGN_COLUMNS_COOKIE)?.value
|
||||
|
||||
return { listId, hiddenColumnIds: parseCampaignHiddenColumns(raw)[listId] ?? [] }
|
||||
}
|
||||
|
|
@ -196,7 +196,9 @@ export type MunicipalityListColumnId =
|
|||
| 'kind'
|
||||
| 'votos'
|
||||
| 'classe'
|
||||
| 'nivel'
|
||||
// Matches the column's own `id` and its `?level=` filter param; the SORT key
|
||||
// for the same column is `nivel`, and both URL spellings are frozen (B18).
|
||||
| 'level'
|
||||
| 'advisors'
|
||||
| 'trend'
|
||||
| 'expectedVotes'
|
||||
|
|
@ -226,7 +228,7 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
|
|||
kind: 'Município inteiro ou zona eleitoral de Salvador (ZE 1–19).',
|
||||
votos: formatMunicipalityConcentrationHint(),
|
||||
classe: CLASS_COLUMN_DESCRIPTION,
|
||||
nivel: campaignConceptOneLiner('nivel-de-envolvimento'),
|
||||
level: campaignConceptOneLiner('nivel-de-envolvimento'),
|
||||
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
|
||||
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
|
||||
expectedVotes: campaignConceptOneLiner('meta'),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import {
|
||||
politicalTrendLabels,
|
||||
territorialClassLabels,
|
||||
type MunicipalityListColumnId,
|
||||
type PoliticalTrendStatus,
|
||||
} from '@/utilities/municipalityLabels'
|
||||
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
|
||||
|
|
@ -92,6 +93,31 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
nivel: 'Nível',
|
||||
}
|
||||
|
||||
/**
|
||||
* B17 — the name each column answers to in the column picker. Ten of the
|
||||
* twelve quote the sort label the header already renders
|
||||
* (`MunicipalitySortableHead` resolves its text from that record), so renaming
|
||||
* a header renames the menu entry with it. The two that differ do so on
|
||||
* purpose: a header sits above its own data and can be telegraphic, while the
|
||||
* same word alone in a list of column names says nothing.
|
||||
*/
|
||||
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
|
||||
name: municipalityListSortLabels.name,
|
||||
region: municipalityListSortLabels.region,
|
||||
kind: municipalityListSortLabels.kind,
|
||||
/** Header is the bare year, under the "2022" group of the table. */
|
||||
votos: 'Votação 2022',
|
||||
classe: municipalityListSortLabels.classe,
|
||||
level: municipalityListSortLabels.nivel,
|
||||
advisors: municipalityListSortLabels.coverage,
|
||||
trend: municipalityListSortLabels.trend,
|
||||
expectedVotes: municipalityListSortLabels.expectedVotes,
|
||||
/** The column shows the signal; `frescor` sorts by how old it is. */
|
||||
lastSignal: 'Último sinal',
|
||||
goalCoverage: municipalityListSortLabels.deficit,
|
||||
lastUpdateAt: municipalityListSortLabels.lastUpdateAt,
|
||||
}
|
||||
|
||||
export type MunicipalityListState = {
|
||||
page: number
|
||||
q?: string
|
||||
|
|
|
|||
100
tests/e2e/campaignColumnPicker.e2e.spec.ts
Normal file
100
tests/e2e/campaignColumnPicker.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* B17 — the column picker is shared by every `CampaignTable` surface, so what
|
||||
* is pinned here is the contract, not one screen: hiding survives a reload
|
||||
* (cookie), one list's choice does not leak into another, and the mandatory
|
||||
* column cannot be unchecked.
|
||||
*
|
||||
* Own file on purpose: `campaignMunicipalities.e2e.spec.ts` carries two
|
||||
* deterministic failures at one worker, ledgered as P1.
|
||||
*/
|
||||
/**
|
||||
* Toggling costs a full RSC round-trip of a heavy list route. In dev, with the
|
||||
* suite's two workers competing for the same server, that overruns the 5 s
|
||||
* default of `expect` — so the assertions that wait on the refresh get their
|
||||
* own budget instead of a flake nobody can reproduce at one worker.
|
||||
*/
|
||||
const REFRESH = { timeout: 20_000 }
|
||||
|
||||
test.describe('Seletor de colunas', () => {
|
||||
test('hides a column, keeps it hidden across reloads and restores it', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
// Crosses two heavy list routes; `/campanha/liderancas` only finishes
|
||||
// compiling in dev when an authenticated request reaches its tree.
|
||||
test.slow()
|
||||
|
||||
const { fixtures } = campaign
|
||||
const password = fixtures.value('senha')
|
||||
const email = `${fixtures.value('colunas-coordinator')}@example.com`
|
||||
await campaign.payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('Coordenadora Colunas'),
|
||||
email,
|
||||
password,
|
||||
role: 'coordinator',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, email, password)
|
||||
await page.goto('/campanha/municipios')
|
||||
|
||||
const kindHeader = page.getByRole('columnheader', { name: /Tipo/ })
|
||||
await expect(kindHeader).toBeVisible()
|
||||
|
||||
const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
|
||||
await picker.click()
|
||||
await page.getByRole('checkbox', { name: 'Tipo' }).click()
|
||||
// Closing flushes the batched cookie write and refreshes the RSC.
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(kindHeader).toHaveCount(0, REFRESH)
|
||||
await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('columnheader', { name: /Tipo/ })).toHaveCount(0, REFRESH)
|
||||
|
||||
// The cookie carries one key per list: another list is untouched. The
|
||||
// territories list is the honest neighbour here — its 27 rows come from
|
||||
// the catalog, so it never falls into an empty state.
|
||||
await page.goto('/campanha/territorios')
|
||||
await expect(page.getByRole('columnheader', { name: /Municípios/ })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Mostrar ou ocultar colunas' })).toBeVisible()
|
||||
|
||||
await page.goto('/campanha/municipios')
|
||||
await page.getByRole('button', { name: /Mostrar ou ocultar colunas/ }).click()
|
||||
await page.getByRole('button', { name: 'Restaurar todas' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: /Tipo/ })).toBeVisible(REFRESH)
|
||||
})
|
||||
|
||||
test('lists the mandatory column without letting it be unchecked', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const password = fixtures.value('senha')
|
||||
const email = `${fixtures.value('colunas-mandatory')}@example.com`
|
||||
await campaign.payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('Coordenadora Obrigatória'),
|
||||
email,
|
||||
password,
|
||||
role: 'coordinator',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, email, password)
|
||||
await page.goto('/campanha/municipios')
|
||||
|
||||
await page.getByRole('button', { name: /Mostrar ou ocultar colunas/ }).click()
|
||||
// The name carries the reason too ("Município sempre visível").
|
||||
const mandatory = page.getByRole('checkbox', { name: /^Município/ })
|
||||
await expect(mandatory).toBeChecked()
|
||||
await expect(mandatory).toBeDisabled()
|
||||
})
|
||||
})
|
||||
152
tests/unit/campaignColumnPicker.unit.spec.ts
Normal file
152
tests/unit/campaignColumnPicker.unit.spec.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColumnPicker'
|
||||
|
||||
const refresh = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) }))
|
||||
|
||||
/**
|
||||
* jsdom scopes `document.cookie` by the document URL, and the real cookie is
|
||||
* written with `path=/campanha` — so a jar that ignores attributes is what
|
||||
* lets these tests read back what the picker wrote.
|
||||
*/
|
||||
let cookieJar = ''
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'name', label: 'Município', mandatory: true },
|
||||
{ id: 'kind', label: 'Tipo' },
|
||||
{ id: 'trend', label: 'Tendência' },
|
||||
]
|
||||
|
||||
const picker = (hiddenColumnIds: string[]) =>
|
||||
createElement(CampaignColumnPicker, {
|
||||
listId: 'municipios' as const,
|
||||
columns: COLUMNS,
|
||||
hiddenColumnIds,
|
||||
})
|
||||
|
||||
const menuTrigger = () => screen.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
|
||||
|
||||
const openMenu = () => fireEvent.click(menuTrigger())
|
||||
|
||||
/** Closing is the commit path — the idle timer is only a safety net. */
|
||||
const closeMenu = () => fireEvent.click(menuTrigger())
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
configurable: true,
|
||||
get: () => cookieJar,
|
||||
set: (value: string) => {
|
||||
const [pair = ''] = value.split('; ')
|
||||
const separator = pair.indexOf('=')
|
||||
const name = pair.slice(0, separator)
|
||||
const others = cookieJar.split('; ').filter((entry) => entry && !entry.startsWith(`${name}=`))
|
||||
cookieJar = [...others, pair].join('; ')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cookieJar = ''
|
||||
refresh.mockClear()
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(document, 'cookie')
|
||||
})
|
||||
|
||||
describe('CampaignColumnPicker', () => {
|
||||
it('batches a whole editing session into one cookie write and one refresh', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
try {
|
||||
render(picker([]))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
// Reading the next label takes longer than any debounce worth having:
|
||||
// a second between checkboxes must still be one refresh, not two.
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
closeMenu()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind~trend')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('commits on its own when the menu is left open', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
try {
|
||||
render(picker([]))
|
||||
openMenu()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never lets the mandatory column be unchecked', () => {
|
||||
render(picker([]))
|
||||
openMenu()
|
||||
|
||||
const mandatory = screen.getByRole('checkbox', { name: /^Município/ })
|
||||
|
||||
expect(mandatory.getAttribute('aria-checked')).toBe('true')
|
||||
expect(mandatory).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('keeps an edit made while the previous one is still in flight', async () => {
|
||||
const { rerender } = render(picker([]))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
closeMenu()
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The second toggle opens a new window …
|
||||
openMenu()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
|
||||
// … and the RSC payload of the FIRST one lands inside it. Adopting it here
|
||||
// would uncheck nothing and re-commit `kind` alone, dropping `trend`.
|
||||
rerender(picker(['kind']))
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'Tendência' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
closeMenu()
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2))
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind~trend')
|
||||
})
|
||||
|
||||
it('adopts the server payload once nothing is pending', async () => {
|
||||
const { rerender } = render(picker([]))
|
||||
|
||||
rerender(picker(['kind']))
|
||||
openMenu()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('checkbox', { name: 'Tipo' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
),
|
||||
)
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
95
tests/unit/campaignColumnVisibility.unit.spec.ts
Normal file
95
tests/unit/campaignColumnVisibility.unit.spec.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
parseCampaignHiddenColumns,
|
||||
resolveVisibleColumns,
|
||||
serializeCampaignHiddenColumns,
|
||||
toggleHiddenColumn,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
|
||||
describe('parseCampaignHiddenColumns', () => {
|
||||
it('reads one list and several lists', () => {
|
||||
expect(parseCampaignHiddenColumns('municipios:kind~trend')).toEqual({
|
||||
municipios: ['kind', 'trend'],
|
||||
})
|
||||
expect(parseCampaignHiddenColumns('municipios:kind|liderancas:organizations')).toEqual({
|
||||
municipios: ['kind'],
|
||||
liderancas: ['organizations'],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the full table when the cookie is absent, empty or malformed', () => {
|
||||
expect(parseCampaignHiddenColumns(undefined)).toEqual({})
|
||||
expect(parseCampaignHiddenColumns('')).toEqual({})
|
||||
expect(parseCampaignHiddenColumns('garbage')).toEqual({})
|
||||
expect(parseCampaignHiddenColumns(':kind')).toEqual({})
|
||||
expect(parseCampaignHiddenColumns('municipios:')).toEqual({})
|
||||
})
|
||||
|
||||
it('drops unknown lists and column ids that are not identifiers', () => {
|
||||
expect(parseCampaignHiddenColumns('assessores:email|municipios:kind')).toEqual({
|
||||
municipios: ['kind'],
|
||||
})
|
||||
expect(parseCampaignHiddenColumns('municipios:kind~<script>~trend')).toEqual({
|
||||
municipios: ['kind', 'trend'],
|
||||
})
|
||||
})
|
||||
|
||||
it('dedupes and refuses an oversized cookie instead of trusting it', () => {
|
||||
expect(parseCampaignHiddenColumns('municipios:kind~kind')).toEqual({ municipios: ['kind'] })
|
||||
expect(parseCampaignHiddenColumns(`municipios:${'a'.repeat(4000)}`)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeCampaignHiddenColumns', () => {
|
||||
it('round-trips through parse and omits lists with nothing hidden', () => {
|
||||
const value = { municipios: ['kind', 'trend'], demandas: ['requester'] }
|
||||
const serialized = serializeCampaignHiddenColumns(value)
|
||||
|
||||
expect(parseCampaignHiddenColumns(serialized)).toEqual(value)
|
||||
expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('')
|
||||
})
|
||||
|
||||
it('never emits a character that would break the cookie value', () => {
|
||||
const serialized = serializeCampaignHiddenColumns({ municipios: ['kind', 'trend'] })
|
||||
|
||||
expect(serialized).not.toMatch(/[;,\s"\\]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleHiddenColumn', () => {
|
||||
it('hides, shows and never duplicates an id', () => {
|
||||
const hidden = toggleHiddenColumn([], 'kind', false)
|
||||
expect(hidden).toEqual(['kind'])
|
||||
|
||||
expect(toggleHiddenColumn(hidden, 'kind', false)).toEqual(['kind'])
|
||||
expect(toggleHiddenColumn(hidden, 'kind', true)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the other columns of the list alone', () => {
|
||||
expect(toggleHiddenColumn(['trend', 'kind'], 'kind', true)).toEqual(['trend'])
|
||||
expect(toggleHiddenColumn(['trend'], 'kind', false)).toEqual(['trend', 'kind'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVisibleColumns', () => {
|
||||
const columns = [{ id: 'name', mandatory: true }, { id: 'kind' }, { id: 'trend' }]
|
||||
|
||||
it('drops the hidden columns and keeps the declared order', () => {
|
||||
expect(resolveVisibleColumns(columns, ['trend']).map((column) => column.id)).toEqual([
|
||||
'name',
|
||||
'kind',
|
||||
])
|
||||
})
|
||||
|
||||
it('never drops a mandatory column, whatever the cookie says', () => {
|
||||
expect(resolveVisibleColumns(columns, ['name', 'kind', 'trend']).map((c) => c.id)).toEqual([
|
||||
'name',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns every column when nothing is hidden', () => {
|
||||
expect(resolveVisibleColumns(columns, undefined)).toHaveLength(3)
|
||||
expect(resolveVisibleColumns(columns, [])).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
|
@ -17,7 +17,7 @@ const renderTable = (cellTooltip?: (row: Row) => string | null) =>
|
|||
columns: [
|
||||
{
|
||||
id: 'name',
|
||||
head: createElement(CampaignTableHead, {}, 'Município'),
|
||||
label: 'Município',
|
||||
cell: (row) => row.name,
|
||||
cellTooltip,
|
||||
},
|
||||
|
|
@ -27,6 +27,38 @@ const renderTable = (cellTooltip?: (row: Row) => string | null) =>
|
|||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* B17: `label` is what the column picker shows, and it is the header too
|
||||
* unless the column asks for more. Declaring both is how the two drift.
|
||||
*/
|
||||
describe('CampaignTable head', () => {
|
||||
it('builds the header from the label when the column declares no head', () => {
|
||||
const html = renderTable()
|
||||
|
||||
expect(html).toContain('>Município<')
|
||||
expect(html.match(/<th[\s>]/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets a column own its header when it needs more than its name', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(CampaignTable<Row>, {
|
||||
columns: [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Município',
|
||||
head: createElement(CampaignTableHead, { align: 'right' }, 'Município'),
|
||||
cell: (row) => row.name,
|
||||
},
|
||||
],
|
||||
rows,
|
||||
rowKey: (row) => row.id,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('text-right')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* B23 seam consumed by E10's "Classe" column: the tooltip is declared by the
|
||||
* column, rendered by the table. These pin the two halves of the contract that
|
||||
|
|
|
|||
Loading…
Reference in a new issue