B17 simplify: inherit header labels, split persist from refresh.
The four *SortableHead islands already fall back to their sort-label record, so B17's new `label` made the header text a third copy instead of removing the second one — 16 redundant `children` deleted, leaving only `frescor`, which diverges on purpose and now reads the record too. The 3 s "safety net" timer was committing like a close, refresh included, which recreated the per-toggle route render the 400 ms debounce was rejected for: at a reading pace every checkbox landed outside the window. The timer now writes the cookie only; closing repaints. A session that ends where it started refreshes nothing, and a menu open when the route unmounts saves from the cleanup, since Radix reports no close event for a sidebar navigation. Both pinned red first. Also: `columnVisibility` required rather than optional for the tests' benefit, the redundant per-list cap dropped, `resolveVisibleColumns` no longer copies, and B29's unused `LeadershipListAccessFilter` export un-exported — knip was failing on main too.
This commit is contained in:
parent
8bf872142b
commit
ed0fb4065f
13 changed files with 150 additions and 109 deletions
|
|
@ -7,7 +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).
|
||||
- **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 como rede, e o `/simplify` separou o que essa rede faz: ela grava **só o cookie, sem refresh** — um timer que também repintasse recriaria o custo por marcação num ritmo mais lento, que é exatamente a falha pela qual os 400 ms do primeiro corte foram recusados (rajada não é ritmo de leitura, e cada perda re-paga os ~229 ms / 371 statements de `/campanha/liderancas`). Durabilidade e repintura são perguntas diferentes, então têm gatilhos diferentes; sessão que termina onde começou não dá refresh, e menu aberto na hora que a rota desmonta grava pelo cleanup (o Radix não emite fechamento numa navegação de sidebar, e a escolha sumia calada) — 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).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -10,7 +10,7 @@ Appetite: revisado para ~1,3 dia eng (o plano previa 0,5–1 dia para **uma** li
|
|||
|
||||
**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.
|
||||
**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 é rede de segurança para um Popover deixado aberto, e grava só o cookie, sem refresh —, 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.
|
||||
|
||||
|
|
@ -32,6 +32,16 @@ Appetite: revisado para ~1,3 dia eng (o plano previa 0,5–1 dia para **uma** li
|
|||
|
||||
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`.
|
||||
|
||||
**Segundo `/simplify` (pós-rebase em `main`), e o achado principal os três revisores encontraram sozinhos.** Os quatro `*SortableHead` **já** resolviam o próprio texto do registro de rótulos (`children ?? municipalityListSortLabels[sortKey]`), e `TerritoryListColumns` já dependia disso — passar `children` iguais ao registro era duplicação pura. O `label` novo do B17 tinha tornado a duplicação **tripla** em vez de apagá-la, e o docblock que promete "renomear o header renomeia a entrada do menu" era falso onde mais parecia verdadeiro. Foram **16** literais removidos (10 em municípios, 4 em lideranças pós-B29, 2 em dobradinhas); sobra um único `children`, o de `frescor`, que diverge de propósito ("Último sinal" na coluna, "Frescor do sinal" na ordenação) e agora lê `municipalityColumnLabels.lastSignal`, então nem esse tem a string escrita duas vezes. O `sortLabel` do `CampaignSortableHead` sempre veio do registro, então o nome acessível não mudou — só o texto visível deixou de ter uma segunda fonte.
|
||||
|
||||
**A cadência ganhou uma separação que faltava.** O timer de 3 s era descrito como rede de segurança, mas commitava igual ao fechamento: refresh incluído. Como três segundos é um intervalo comum entre cliques quando se está lendo rótulos desconhecidos, ele reproduzia — mais devagar — exatamente a falha pela qual os 400 ms foram recusados. Agora `persist()` (timer) grava só o cookie e `commit()` (fechar) grava e repinta. Duas guardas nasceram com a separação: **sessão que termina onde começou não dá refresh** (`sameIdSet` contra o conjunto renderizado — marcar e desmarcar a mesma coluna re-pagava a rota inteira para produzir a mesma página), e **desmontar com o menu aberto grava pelo cleanup**, porque o Radix não emite `onOpenChange` numa navegação de sidebar e a escolha sumia sem aviso. As duas especificações foram verificadas vermelhas contra o código anterior. Com as guardas dentro de `commit`, o `open` controlado virou supérfluo e saiu.
|
||||
|
||||
**Miudezas do mesmo passe:** `columnVisibility` era opcional em `MunicipalityList` **só** para poupar os testes (o comentário dizia isso), então virou obrigatório e o objeto de props compartilhado dos specs ganhou o valor; `MAX_HIDDEN_PER_LIST` era um segundo teto sem ameaça atrás dele (id que não casa com coluna nenhuma é inerte) e ainda assimétrico, porque `serialize` não o aplicava — o teto de tamanho do cookie basta; `resolveVisibleColumns` copiava o array no caminho comum só para satisfazer o tipo de retorno, que agora é `readonly`; o tipo da coluna de municípios, escrito três vezes, virou um alias local; e o mock de `next/navigation` do spec passou a espalhar o módulo real como os três specs irmãos, em vez de substituí-lo inteiro.
|
||||
|
||||
**Herdado do rebase, corrigido aqui porque o gate exige:** `LeadershipListAccessFilter` (B29) era exportado sem consumidor externo, e knip trata export morto como ERROR — `pnpm exec knip` falhava em `origin/main` também.
|
||||
|
||||
**Recusado com razão, e o motivo importa mais que a recusa.** Mover as leituras de `readCampaignColumnVisibility` para **antes** do `Promise.all` das páginas permitiria pular a query da coluna oculta — `/campanha/dobradinhas` roda `loadLeadershipOptions` (`limit: 0`, `depth: 1`, toda liderança visível) mesmo com a coluna "Lideranças" escondida, e `cookies()` não faz I/O, então a leitura antecipada é grátis. É o maior ganho disponível e **não** é limpeza: carregamento condicional muda o que cada página garante ao construir as colunas. Fica registrado como fill-in do B17, não feito neste passe. Também recusados: adotar o shell de Popover do `CampaignHeaderFilterPopover` (as linhas dele são âncoras de navegação, as do picker são checkboxes — seriam dois componentes com um nome só); mover `readCookie`/`writeCookie` para um módulo irmão de `recentVisits.ts` (placement melhor, ganho cosmético); e dar `children` opcional ao `LeadershipFilterHead` do B29 para matar os dois últimos literais de lideranças (mexe na API de uma entrega recém-chegada).
|
||||
|
||||
## Desenho original (2026-07-24, preservado)
|
||||
|
||||
## Design (Impeccable)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -91,11 +91,7 @@ const stateDeputyColumns = (
|
|||
id: 'name',
|
||||
label: stateDeputyListSortLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<StateDeputySortableHead state={state} sortKey="name">
|
||||
Nome
|
||||
</StateDeputySortableHead>
|
||||
),
|
||||
head: <StateDeputySortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<Link
|
||||
href={`/campanha/dobradinhas/${row.slug}`}
|
||||
|
|
@ -114,9 +110,7 @@ const stateDeputyColumns = (
|
|||
sortKey="party"
|
||||
filterOptions={partyFilterOptions}
|
||||
hasNoPartyOption={hasNoPartyOption}
|
||||
>
|
||||
Partido
|
||||
</StateDeputySortableHead>
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-muted-foreground',
|
||||
cell: (row) => row.party ?? '—',
|
||||
|
|
|
|||
|
|
@ -129,11 +129,7 @@ const leadershipColumns = ({
|
|||
id: 'name',
|
||||
label: leadershipListSortLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<LeadershipSortableHead state={state} sortKey="name">
|
||||
Nome
|
||||
</LeadershipSortableHead>
|
||||
),
|
||||
head: <LeadershipSortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<Link
|
||||
href={`/campanha/liderancas/${row.id}`}
|
||||
|
|
@ -165,9 +161,7 @@ const leadershipColumns = ({
|
|||
id: 'supportStatus',
|
||||
label: leadershipListSortLabels.supportStatus,
|
||||
head: (
|
||||
<LeadershipSortableHead state={state} sortKey="supportStatus" filterParam="supportStatus">
|
||||
Status
|
||||
</LeadershipSortableHead>
|
||||
<LeadershipSortableHead state={state} sortKey="supportStatus" filterParam="supportStatus" />
|
||||
),
|
||||
cell: (row) => (
|
||||
<LeadershipListSupportStatusControl leadershipID={row.id} status={row.supportStatus} />
|
||||
|
|
@ -176,11 +170,7 @@ const leadershipColumns = ({
|
|||
{
|
||||
id: 'sector',
|
||||
label: leadershipListSortLabels.sector,
|
||||
head: (
|
||||
<LeadershipSortableHead state={state} sortKey="sector" filterParam="sector">
|
||||
Setor
|
||||
</LeadershipSortableHead>
|
||||
),
|
||||
head: <LeadershipSortableHead state={state} sortKey="sector" filterParam="sector" />,
|
||||
cell: (row) => {
|
||||
if (!row.sector || !isLeadershipSector(row.sector)) return row.sector || '—'
|
||||
return leadershipSectorLabels[row.sector]
|
||||
|
|
@ -258,11 +248,7 @@ const leadershipColumns = ({
|
|||
{
|
||||
id: 'updatedAt',
|
||||
label: leadershipListSortLabels.updatedAt,
|
||||
head: (
|
||||
<LeadershipSortableHead state={state} sortKey="updatedAt">
|
||||
Última atualização
|
||||
</LeadershipSortableHead>
|
||||
),
|
||||
head: <LeadershipSortableHead state={state} sortKey="updatedAt" />,
|
||||
cell: (row) => {
|
||||
const absolute = formatBahiaDateTimeLabel(row.updatedAt)
|
||||
const relative = formatRelativeAge(new Date(row.updatedAt).getTime(), nowMs)
|
||||
|
|
|
|||
|
|
@ -104,8 +104,7 @@ export type MunicipalityListProps = {
|
|||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
state: MunicipalityListState
|
||||
/** B17 — optional only because the unit tests render this list without a cookie. */
|
||||
columnVisibility?: CampaignColumnVisibility
|
||||
columnVisibility: CampaignColumnVisibility
|
||||
}
|
||||
|
||||
const VotePositionReadout = ({
|
||||
|
|
@ -252,6 +251,14 @@ const advisorNames = (
|
|||
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
|
||||
|
||||
/** The desktop table as column definitions (Pass 2 W1 list system). */
|
||||
/**
|
||||
* The id is the picker's key, the cookie's key and the key of the B22
|
||||
* description record, so it is typed instead of widening to `string`.
|
||||
*/
|
||||
type MunicipalityColumn = CampaignTableColumn<MunicipalityListViewModel> & {
|
||||
id: MunicipalityListColumnId
|
||||
}
|
||||
|
||||
const municipalityListColumns = ({
|
||||
state,
|
||||
isStaffView,
|
||||
|
|
@ -261,9 +268,7 @@ const municipalityListColumns = ({
|
|||
advisorNamesById,
|
||||
advisorOptions,
|
||||
signalFormAction,
|
||||
}: MunicipalityListProps): Array<
|
||||
CampaignTableColumn<MunicipalityListViewModel> & { id: MunicipalityListColumnId }
|
||||
> => [
|
||||
}: MunicipalityListProps): Array<MunicipalityColumn> => [
|
||||
{
|
||||
id: 'name',
|
||||
label: municipalityColumnLabels.name,
|
||||
|
|
@ -277,9 +282,7 @@ const municipalityListColumns = ({
|
|||
showPriorityFilter={isStaffView}
|
||||
description={municipalityColumnDescriptions.name}
|
||||
className="sticky left-0 z-20 min-w-56 bg-background"
|
||||
>
|
||||
Município
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cellClassName: 'sticky left-0 z-[5] min-w-56 whitespace-normal bg-background',
|
||||
cell: (municipality) => (
|
||||
|
|
@ -306,9 +309,7 @@ const municipalityListColumns = ({
|
|||
filterParam="region"
|
||||
filterOptions={columnFilterOptions.region}
|
||||
description={municipalityColumnDescriptions.region}
|
||||
>
|
||||
Território
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (municipality) => <TerritoryLink region={municipality.region} />,
|
||||
|
|
@ -322,9 +323,7 @@ const municipalityListColumns = ({
|
|||
sortKey="kind"
|
||||
filterParam="kind"
|
||||
description={municipalityColumnDescriptions.kind}
|
||||
>
|
||||
Tipo
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => municipalityKindLabels[municipality.kind],
|
||||
},
|
||||
|
|
@ -360,9 +359,7 @@ const municipalityListColumns = ({
|
|||
sortKey="classe"
|
||||
filterParam="class"
|
||||
description={municipalityColumnDescriptions.classe}
|
||||
>
|
||||
Classe
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
|
||||
// The factors behind the label — the cell keeps them as `sr-only`
|
||||
|
|
@ -382,9 +379,7 @@ const municipalityListColumns = ({
|
|||
sortKey="nivel"
|
||||
filterParam="level"
|
||||
description={municipalityColumnDescriptions.level}
|
||||
>
|
||||
Nível
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
canMoveEngagementLevel ? (
|
||||
|
|
@ -422,9 +417,7 @@ const municipalityListColumns = ({
|
|||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
description={municipalityColumnDescriptions.advisors}
|
||||
>
|
||||
Assessores
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
isCoordinator ? (
|
||||
|
|
@ -460,9 +453,7 @@ const municipalityListColumns = ({
|
|||
sortKey="trend"
|
||||
filterParam="trend"
|
||||
description={municipalityColumnDescriptions.trend}
|
||||
>
|
||||
Tendência
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListTrendControl
|
||||
|
|
@ -483,9 +474,7 @@ const municipalityListColumns = ({
|
|||
sortKey="expectedVotes"
|
||||
align="center"
|
||||
description={municipalityColumnDescriptions.expectedVotes}
|
||||
>
|
||||
Votos estimados
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cellClassName: 'relative overflow-visible align-middle text-center',
|
||||
cell: (municipality) => (
|
||||
|
|
@ -509,7 +498,9 @@ const municipalityListColumns = ({
|
|||
sortKey="frescor"
|
||||
description={municipalityColumnDescriptions.lastSignal}
|
||||
>
|
||||
Último sinal
|
||||
{/* The one header that does NOT inherit its sort label: the column
|
||||
shows the signal, `frescor` sorts by how old it is. */}
|
||||
{municipalityColumnLabels.lastSignal}
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
|
|
@ -533,9 +524,7 @@ const municipalityListColumns = ({
|
|||
state={state}
|
||||
sortKey="deficit"
|
||||
description={municipalityColumnDescriptions.goalCoverage}
|
||||
>
|
||||
Cobertura da meta
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListGoalCoverageCell
|
||||
|
|
@ -543,9 +532,7 @@ const municipalityListColumns = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<
|
||||
CampaignTableColumn<MunicipalityListViewModel> & { id: MunicipalityListColumnId }
|
||||
>)
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: ([
|
||||
{
|
||||
id: 'lastUpdateAt',
|
||||
|
|
@ -555,18 +542,14 @@ const municipalityListColumns = ({
|
|||
state={state}
|
||||
sortKey="lastUpdateAt"
|
||||
description={municipalityColumnDescriptions.lastUpdateAt}
|
||||
>
|
||||
Última atualização
|
||||
</MunicipalitySortableHead>
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização',
|
||||
},
|
||||
] satisfies Array<
|
||||
CampaignTableColumn<MunicipalityListViewModel> & { id: MunicipalityListColumnId }
|
||||
>)),
|
||||
] satisfies Array<MunicipalityColumn>)),
|
||||
]
|
||||
|
||||
export const MunicipalityList = (props: MunicipalityListProps) => {
|
||||
|
|
@ -579,7 +562,7 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
// `mandatory` cannot be on screen while the caption denies it.
|
||||
const showsVoteColumn = resolveVisibleColumns(
|
||||
columns,
|
||||
props.columnVisibility?.hiddenColumnIds,
|
||||
props.columnVisibility.hiddenColumnIds,
|
||||
).some((column) => column.id === 'votos')
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -34,15 +34,21 @@ type CampaignColumnPickerProps = {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Closing the menu is what refreshes the table — an editing session is one
|
||||
* navigation, not one per checkbox, because each refresh re-runs the whole
|
||||
* route (on `/campanha/liderancas`, the ~229 ms / 371-statement render ledgered
|
||||
* as P1).
|
||||
*
|
||||
* The timer exists so a menu left open does not lose the choice, and it writes
|
||||
* the cookie WITHOUT refreshing. Any timer that also refreshed would recreate
|
||||
* the per-toggle cost at a slower pace: three seconds is an ordinary gap
|
||||
* between clicks when the labels are being read. Durability and repaint are
|
||||
* separate questions, so they get separate triggers.
|
||||
*
|
||||
* It reads refs, 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 PERSIST_IDLE_MS = 3_000
|
||||
|
||||
const readCookie = () =>
|
||||
parseCampaignHiddenColumns(
|
||||
|
|
@ -77,11 +83,12 @@ export const CampaignColumnPicker = ({
|
|||
// 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)
|
||||
/** What the cookie already holds, so an undone edit writes nothing. */
|
||||
const persistedIds = useRef<readonly string[]>(hiddenColumnIds)
|
||||
const persistTimer = 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
|
||||
|
|
@ -89,25 +96,44 @@ export const CampaignColumnPicker = ({
|
|||
// 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) {
|
||||
if (!sameIdSet(serverIds, hiddenColumnIds) && persistTimer.current === null) {
|
||||
setServerIds(hiddenColumnIds)
|
||||
setHidden([...hiddenColumnIds])
|
||||
latestHidden.current = [...hiddenColumnIds]
|
||||
// The payload we just adopted was rendered FROM the cookie, so it is also
|
||||
// what the cookie holds — otherwise the next write would look redundant.
|
||||
persistedIds.current = hiddenColumnIds
|
||||
}
|
||||
|
||||
// A menu still open when the route unmounts (sidebar navigation) never gets
|
||||
// its close event, so the cookie is written here instead. No refresh: the
|
||||
// navigation already renders the next route, which reads the cookie.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (commitTimer.current) clearTimeout(commitTimer.current)
|
||||
if (!persistTimer.current) return
|
||||
clearTimeout(persistTimer.current)
|
||||
if (sameIdSet(persistedIds.current, latestHidden.current)) return
|
||||
writeCookie(listId, latestHidden.current)
|
||||
},
|
||||
[],
|
||||
[listId],
|
||||
)
|
||||
|
||||
const commit = () => {
|
||||
if (commitTimer.current) {
|
||||
clearTimeout(commitTimer.current)
|
||||
commitTimer.current = null
|
||||
/** Durability, on its own: the cookie only, never a repaint. */
|
||||
const persist = () => {
|
||||
if (persistTimer.current) {
|
||||
clearTimeout(persistTimer.current)
|
||||
persistTimer.current = null
|
||||
}
|
||||
if (sameIdSet(persistedIds.current, latestHidden.current)) return
|
||||
persistedIds.current = latestHidden.current
|
||||
writeCookie(listId, latestHidden.current)
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
persist()
|
||||
// Toggling a column and undoing it inside one session leaves the rendered
|
||||
// table already correct; refreshing it would re-pay the route for nothing.
|
||||
if (sameIdSet(serverIds, latestHidden.current)) return
|
||||
startTransition(() => {
|
||||
router.refresh()
|
||||
})
|
||||
|
|
@ -115,12 +141,12 @@ export const CampaignColumnPicker = ({
|
|||
|
||||
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.
|
||||
// drop each other, and the timer reads the ref.
|
||||
latestHidden.current = next
|
||||
setHidden(next)
|
||||
|
||||
if (commitTimer.current) clearTimeout(commitTimer.current)
|
||||
commitTimer.current = setTimeout(commit, COMMIT_IDLE_MS)
|
||||
if (persistTimer.current) clearTimeout(persistTimer.current)
|
||||
persistTimer.current = setTimeout(persist, PERSIST_IDLE_MS)
|
||||
}
|
||||
|
||||
const toggle = (columnId: string, visible: boolean) => {
|
||||
|
|
@ -135,11 +161,11 @@ export const CampaignColumnPicker = ({
|
|||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
// Closing is the commit: the whole session lands in one refresh.
|
||||
if (!next && commitTimer.current) commit()
|
||||
// Closing is the commit: the whole session lands in one refresh. Both
|
||||
// halves of `commit` are guarded, so a close with nothing pending — or
|
||||
// one fired by a layer sweep rather than by the user — costs nothing.
|
||||
if (!next) commit()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ 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',
|
||||
|
|
@ -69,6 +68,9 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid
|
|||
const listId = entry.slice(0, separatorIndex)
|
||||
if (!isCampaignListId(listId)) continue
|
||||
|
||||
// An id matching nothing on the table is inert, so the length cap above is
|
||||
// the only bound worth having — a per-list one would also have to be
|
||||
// mirrored in `serialize`, or writing 41 and reading back 40 would differ.
|
||||
const columnIds = [
|
||||
...new Set(
|
||||
entry
|
||||
|
|
@ -76,7 +78,7 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid
|
|||
.split(COLUMN_SEPARATOR)
|
||||
.filter((columnId) => COLUMN_ID_PATTERN.test(columnId)),
|
||||
),
|
||||
].slice(0, MAX_HIDDEN_PER_LIST)
|
||||
]
|
||||
|
||||
if (columnIds.length) parsed[listId] = columnIds
|
||||
}
|
||||
|
|
@ -116,8 +118,8 @@ type HideableColumn = { id: string; mandatory?: boolean }
|
|||
export const resolveVisibleColumns = <Column extends HideableColumn>(
|
||||
columns: readonly Column[],
|
||||
hiddenColumnIds: readonly string[] | undefined,
|
||||
): Column[] => {
|
||||
if (!hiddenColumnIds?.length) return [...columns]
|
||||
): readonly Column[] => {
|
||||
if (!hiddenColumnIds?.length) return columns
|
||||
|
||||
const hidden = new Set(hiddenColumnIds)
|
||||
return columns.filter((column) => column.mandatory || !hidden.has(column.id))
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export type LeadershipListSortKey = 'name' | 'supportStatus' | 'sector' | 'updat
|
|||
export type LeadershipListSortDirection = 'asc' | 'desc'
|
||||
|
||||
/** Acesso ao app — exclusive filter (com | sem), same shape as municipality coverage. */
|
||||
export type LeadershipListAccessFilter = 'com' | 'sem'
|
||||
type LeadershipListAccessFilter = 'com' | 'sem'
|
||||
|
||||
type LeadershipSector = (typeof leadershipSectors)[number]
|
||||
|
||||
|
|
|
|||
|
|
@ -95,11 +95,12 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
|
||||
/**
|
||||
* 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.
|
||||
* twelve quote the sort label, and the header renders that same record
|
||||
* (`MunicipalitySortableHead` falls back to it when given no children, which
|
||||
* is why those heads pass none), 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. `lastSignal` is read back by the header it names.
|
||||
*/
|
||||
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
|
||||
name: municipalityListSortLabels.name,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColum
|
|||
|
||||
const refresh = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) }))
|
||||
// Spread the real module, like the sibling specs: replacing it wholesale would
|
||||
// break the day the picker renders anything reading `usePathname`.
|
||||
vi.mock('next/navigation', async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useRouter: () => ({ refresh }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* jsdom scopes `document.cookie` by the document URL, and the real cookie is
|
||||
|
|
@ -85,7 +90,7 @@ describe('CampaignColumnPicker', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('commits on its own when the menu is left open', async () => {
|
||||
it('saves without repainting when the menu is left open', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
try {
|
||||
|
|
@ -95,13 +100,42 @@ describe('CampaignColumnPicker', () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
// The idle timer is durability, not the commit: a slow reader who takes
|
||||
// more than the interval between checkboxes must not pay a route render
|
||||
// per column, which is the failure the 400 ms debounce was rejected for.
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind')
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
||||
closeMenu()
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not refresh a session that ends where it started', () => {
|
||||
render(picker(['kind']))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
closeMenu()
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves a pending edit when the list unmounts with the menu open', () => {
|
||||
const { unmount } = render(picker([]))
|
||||
openMenu()
|
||||
|
||||
// Radix reports no close event here, so without the unmount flush this
|
||||
// choice would be lost to a sidebar navigation with no feedback at all.
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
unmount()
|
||||
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind')
|
||||
})
|
||||
|
||||
it('never lets the mandatory column be unchecked', () => {
|
||||
render(picker([]))
|
||||
openMenu()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBa
|
|||
import { Progress } from '@/components/ui/Progress'
|
||||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||||
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { createEmptyGoalCoverageByScenario } from '@/utilities/goalCoverage'
|
||||
|
|
@ -41,6 +42,10 @@ const noopListFormAction = async (
|
|||
|
||||
const municipalityListDefaultProps = {
|
||||
isCoordinator: false,
|
||||
columnVisibility: {
|
||||
listId: 'municipios',
|
||||
hiddenColumnIds: [],
|
||||
} satisfies CampaignColumnVisibility,
|
||||
canMoveEngagementLevel: false,
|
||||
advisorOptions: [],
|
||||
columnFilterOptions: { name: [], region: [], advisor: [] },
|
||||
|
|
|
|||
Loading…
Reference in a new issue