Merge pull request #381 from fsolla/agent/B158-colunas-responsivas-municipios
B158 — Colunas responsivas por largura do conteúdo nos municípios
This commit is contained in:
commit
8a44ed5d20
17 changed files with 1318 additions and 463 deletions
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-05):** **B158** (Issue #364) — `/campanha/municipios` troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e `campaign_columns=municipios:__none__` diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção [`colunas-responsivas-municipios.md`](docs/plans/colunas-responsivas-municipios.md); impl [`colunas-responsivas-municipios-impl.md`](docs/plans/colunas-responsivas-municipios-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-05):** **B159** (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de `/campanha/municipios` agora compartilham `MunicipalityRelationEditor` (`CampaignCellEditOverlay` + Command + chips + optimistic reconcile) e `MunicipalityRelationAvatarStack`; Dobradinhas deixa `RelationChipCell` só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (`Contact.phone = null`, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção [`generalizar-colunas-relacao-municipios.md`](docs/plans/generalizar-colunas-relacao-municipios.md); impl [`generalizar-colunas-relacao-municipios-impl.md`](docs/plans/generalizar-colunas-relacao-municipios-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-04):** **B155** (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). **Sem migration**: leitura por query reversa de `leadership.municipalities` (batch no bundle, `loadMunicipalityLeadershipSummaries`) e escrita reusando B34 (`setLeadershipMunicipalitiesMembershipRecord`) + wizard create (`createLeadershipWizardRecord`) — fonte única de verdade, consistência automática com `/campanha/liderancas`. Rota nova `POST /campanha/municipios/leaderships` (`campaignJsonMutationRoute`, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: **célula → rota, form → server action** (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de `/campanha/liderancas`). Intenção [`liderancas-coluna-municipios.md`](docs/plans/liderancas-coluna-municipios.md); impl [`liderancas-coluna-municipios-impl.md`](docs/plans/liderancas-coluna-municipios-impl.md).
|
||||
|
|
|
|||
190
docs/plans/colunas-responsivas-municipios-impl.md
Normal file
190
docs/plans/colunas-responsivas-municipios-impl.md
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# Impl: B158 — Colunas adaptativas na tabela de Municípios
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-05
|
||||
Issue: #364
|
||||
Intenção: docs/plans/colunas-responsivas-municipios.md
|
||||
Appetite restante: ~1,5–2 dias eng; preservar o corte de um único outcome verificável
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `/campanha/municipios` responde à largura real do painel de conteúdo: cards abaixo de 48rem; acima disso, uma tabela sem scroll horizontal que acrescenta contexto progressivamente, preserva preferência manual e mantém os quick edits acessíveis.
|
||||
- **O que NÃO negociar:** container query em vez de viewport/JavaScript; ordem C0–C6; `leader` continua fora da rota; Dobradinha só para coordinator/candidate; território continua filtrável e `sort=region` continua válido; nenhum editor duplicado entre coluna adaptativa e `actions`; nenhuma migration, collection, Consent, server action ou mudança de URL pública.
|
||||
- **O que reavaliar:** a intenção sugere um seam em `CampaignTable`, mas o código atual já aceita `className` em cada head e `cellClassName` em cada célula; a política responsiva pode e deve permanecer no dono local. A frase “uma instância por linha” será verificada dentro da superfície de tabela, porque cards e tabela já são árvores irmãs simultâneas no DOM, ocultadas por CSS; desmontar a superfície inativa exigiria estado/JavaScript e reabriria B42 fora do appetite.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Cookie[Cookie campaign_columns] --> Parse[Parser preserva ausente vs vazio explícito]
|
||||
Parse --> Defaults[Defaults só quando municipios está ausente]
|
||||
Defaults --> Manual[Visibilidade manual no servidor]
|
||||
Manual --> Columns[Definições locais de colunas]
|
||||
Width[Named container municipality-list] --> Capacity[Classes C0 a C6]
|
||||
Capacity --> Columns
|
||||
Columns --> Cards[Cards abaixo de 48rem]
|
||||
Columns --> Table[Tabela a partir de 48rem]
|
||||
Manual --> Actions[actions somente para trend ou lastSignal ocultos]
|
||||
```
|
||||
|
||||
### Decisão 1 — onde vive a política responsiva
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- A. Classes de container query locais nas definições de `MunicipalityList`, aplicadas simetricamente a head/cell.
|
||||
- B. Generalizar `CampaignTableColumn` com prioridade/breakpoint responsivo.
|
||||
- C. Medir largura com `ResizeObserver` e recalcular colunas no cliente.
|
||||
|
||||
**Recomendação:** A — `CampaignTable` continua um módulo profundo para renderização e preferência manual; a matriz C0–C6 é política exclusiva de Municípios e todos os heads já aceitam `className`. Um record local de classes literais e tipadas evita divergência entre `<th>` e `<td>` sem criar API compartilhada prematura.
|
||||
|
||||
**Rejeitadas:** B vazaria uma política de uma lista para todas as tabelas e transformaria breakpoints de produto em configuração genérica; C adicionaria hidratação, estado e risco de flicker para um problema resolvido nativamente por CSS.
|
||||
|
||||
### Decisão 2 — ausência vs vazio explícito no cookie B17
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- A. Sentinel reservado `__none__` no formato atual (`municipios:__none__`).
|
||||
- B. Cookie novo em JSON/versionado, migrando todos os consumidores.
|
||||
- C. Manter o cookie atual e guardar um segundo cookie de “configurado”.
|
||||
|
||||
**Recomendação:** A — é retrocompatível, cabe no parser/serializer puro existente e não cria uma segunda fonte de verdade. O parser trata o sentinel somente como vazio explícito; ele nunca emerge como column ID. O serializer usa presença da chave para distinguir “ausente” de `[]`.
|
||||
|
||||
**Rejeitadas:** B amplia a migração e o risco de decoding entre `document.cookie` e `cookies()` sem benefício; C duplica estado e permite combinações contraditórias.
|
||||
|
||||
### Decisão 3 — montagem única de Tendência/Sinal
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- A. Definir uma função local por controle e chamá-la ou na coluna real ou em `actions`, conforme a ocultação manual resolvida no servidor.
|
||||
- B. Renderizar coluna e `actions` sempre, escondendo uma cópia com CSS.
|
||||
- C. Mover quick edits para um menu genérico de ações por linha.
|
||||
|
||||
**Recomendação:** A — apenas uma célula chama cada factory por linha. A coluna adaptativa permanece montada ao variar capacidade e troca compacto ↔ completo apenas por CSS; `actions` só é incluída na definição quando `trend` e/ou `lastSignal` está manualmente oculta.
|
||||
|
||||
**Rejeitadas:** B viola o contrato de uma instância e duplica estado/autosave; C remove edição “onde se vê”, muda o fluxo aprovado e cria uma abstração rasa.
|
||||
|
||||
### Decisão 4 — prova de responsividade e compatibilidade
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- A. Apenas snapshots/static markup e inspeção manual.
|
||||
- B. Unit/static markup + Playwright medindo o named container em cada corte + interação real com sidebar/chat + projeto WebKit focado.
|
||||
- C. Expandir toda a suíte campaign para uma matriz permanente Chromium/WebKit.
|
||||
|
||||
**Recomendação:** B — prova o mecanismo pelo tamanho do container, não por proxy de viewport, e cobre sticky/container queries em WebKit sem duplicar a suíte inteira. O spec focado roda no projeto Chromium já existente e em um projeto WebKit dedicado; CI instala somente os dois engines necessários.
|
||||
|
||||
**Rejeitadas:** A não detecta overflow nem erros de corte; C aumenta muito o tempo/custo de CI para uma necessidade localizada. WebKit é a automação disponível no Linux para compatibilidade Safari; uma checagem em Safari/iPad físico continua sendo validação de lançamento, não gate reproduzível deste agente.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`parseCampaignHiddenColumns` / `serializeCampaignHiddenColumns` / resolver de defaults** (`src/lib/campaignColumnVisibility.ts`): preservar chave ausente versus vazio explícito, declarar o sentinel reservado e o preset de Municípios (`goalCoverage`, `lastSignal`) sem alterar defaults das outras listas.
|
||||
- **`readCampaignColumnVisibility`** (`src/utilities/campaignColumnVisibilityCookie.ts`): aplicar preset somente quando a entrada da lista está ausente; devolver `[]` quando o sentinel registra “todas visíveis”.
|
||||
- **`CampaignColumnPicker`** (`src/components/campaign/shared/CampaignColumnPicker.tsx`): manter o fluxo atual; `Restaurar todas` já chama `apply([])` e passará a persistir vazio explícito por efeito do serializer. Ajustar comentários/testes, não criar estado novo.
|
||||
- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`):
|
||||
- envolver cards+tabela em `@container/municipality-list` com seletor estável de teste;
|
||||
- reordenar colunas para Nome, 2022, 2026, Nível, Classe, Assessor, Tendência, Liderança, Dobradinha, Cobertura, Sinal, Ações;
|
||||
- mover `TerritoryLink` para a segunda linha do nome e retirar `region` da tabela/picker;
|
||||
- declarar `MunicipalityTableColumnId = MunicipalityListColumnId | 'actions'` localmente;
|
||||
- declarar classes C0–C6 como strings literais, reutilizadas em head/cell;
|
||||
- calcular ocultação manual uma vez e incluir `actions` somente quando necessário;
|
||||
- remover `overflow-x-auto` local e manter o wrapper sem scroller horizontal;
|
||||
- preservar legenda/caption, inclusive `sort=region` ativo.
|
||||
- **`MunicipalityListMobileCards`** (`src/components/campaign/municipality/MunicipalityListMobileCards.tsx`): trocar somente `md:hidden` pela variante do named container; conteúdo/interação ficam intactos.
|
||||
- **`MunicipalityListTrendControl`** (`src/components/campaign/municipality/MunicipalityListTrendControl.tsx`): seam tipado de trigger adaptativo para a tabela; uma instância contém ícone compacto e badge/readout completo alternados por CSS. O botão continua com nome acessível textual e sem depender apenas de cor.
|
||||
- **`MunicipalityListSignalControl`** (`src/components/campaign/municipality/MunicipalityListSignalControl.tsx`): manter o slot `children`; o chamador fornece, na mesma instância, trigger compacto e `SignalAgeReadout` completo. Em `actions`, fornecer somente o trigger compacto apropriado.
|
||||
- **`MunicipalityListLevelControl` / `MunicipalityLevelBadge`** (`src/components/campaign/municipality/`): a face vazia na célula de tabela passa a `—`, preservando “Sem nível” no `aria-label`, filtro e formulários/cards.
|
||||
- **Labels e URL** (`src/utilities/municipality/municipalityLabels.ts`, `src/utilities/municipality/municipalityListUrl.ts`): retirar `region` apenas de `MunicipalityListColumnId`/records de coluna; manter `region` em sort/parser/serializer/resumo. Separar label de picker (`Estimativa 2026`) do head telegráfico (`2026`) e usar singular nos demais heads aprovados.
|
||||
- **Filtros** (`MunicipalityFilters.tsx`, `municipalityOmnibox.ts`): sem mudança comportamental; regressões garantem `regionFilterOptions`, chips e `?region=`.
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** nenhuma mudança; a composição por papel continua usando `isStaffView` e `isCampaignUnrestricted` existentes.
|
||||
- **UI:** Impeccable Adapt, modo Operate. Shape já está fixado pela matriz; execução segue craft → critique → polish em duas rodadas visuais no máximo. Reusar tokens e shells existentes; sem redesign.
|
||||
- **Documentação:** entrada curta em `docs/CHANGELOG-AGENTS.md`; não alterar `docs/ARCHITECTURE.md`, pois nenhuma fronteira muda.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- **Forma escolhida:** tabela essencial + disclosure progressivo por coluna, com cards no container estreito.
|
||||
- **Por quê:** nome/2022/2026/nível/classe sustentam decisão imediata; responsabilidade e rede entram à medida que o painel comporta; trend/sinal preservam ação por trigger compacto.
|
||||
- **Rejeitadas:** scroll horizontal mantém informação mas esconde contexto fora da dobra; tabela fixa reduz legibilidade; cards em toda largura desperdiçam densidade operacional.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer: preferência + estrutura local (~30%)**
|
||||
- Evoluir parser/serializer/defaults com unit tests de round-trip e legado.
|
||||
- Reordenar/remover `region`, criar named container, trocar cards↔tabela em 48rem e aplicar C0 essencial.
|
||||
- Rodar testes unitários focados e `pnpm gate:fast`.
|
||||
2. **Controles adaptativos + matriz completa (~35%)**
|
||||
- Implementar `actions`, factories únicas e estados compacto/completo.
|
||||
- Aplicar C1–C6 simetricamente em heads/cells, headers curtos, nome+território e nível vazio.
|
||||
- Craft com conteúdo real/nomes longos; calibrar cortes uma única vez por evidência de `scrollWidth`.
|
||||
3. **Browser + compatibilidade (~25%)**
|
||||
- Spec Playwright focado com larguras 1px antes/depois de 48/54/60/66/72/78/84rem.
|
||||
- Medir `scrollWidth <= clientWidth`, visibilidade cards/tabela, ordem/entrada, defaults, `actions` e uma instância por controle dentro da tabela.
|
||||
- Remover largura forçada e validar que sidebar e chat alteram a etapa sem mudar a viewport.
|
||||
- Rodar o mesmo spec em Chromium e WebKit; incluir cenário de viewport iPad para sticky/touch targets.
|
||||
4. **Polish e gates (~10%)**
|
||||
- Critique/polish em lote, teclado/foco/nomes acessíveis e alvo mínimo de 44px.
|
||||
- Atualizar changelog.
|
||||
- `pnpm gate:fast`, testes E2E focados, `pnpm exec knip`, `pnpm check:cycles`; entrega via `pnpm push`.
|
||||
|
||||
## Verificação prevista
|
||||
|
||||
### Unit/static markup
|
||||
|
||||
- `tests/unit/campaignColumnVisibility.unit.spec.ts`: ausente → sem chave; sentinel → chave com `[]`; round-trip explícito; entradas legadas e outras listas intactas.
|
||||
- `tests/unit/campaignColumnPicker.unit.spec.ts`: “Restaurar todas” escreve `municipios:__none__`; toggles partem do preset recebido e persistem uma única sessão.
|
||||
- `tests/unit/campaignComponents.unit.spec.ts`: ordem de headers por papel, `region` fora da tabela/picker, território sob nome, `actions` fora do picker, nível nulo `—`, classes simétricas e quantidade de triggers por cenário manual dentro do `<table>`.
|
||||
- `tests/unit/municipalityList.unit.spec.ts` e `municipalityOmnibox.unit.spec.ts`: `sort=region`, resumo, filtros/chips/serialização continuam válidos.
|
||||
|
||||
### Browser/E2E
|
||||
|
||||
- Novo spec focado `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts`.
|
||||
- Named container selecionável por atributo estável; largura controlada diretamente para provar os cortes.
|
||||
- Defaults sem cookie: Cobertura/Sinal ocultos e ação de Sinal disponível; sentinel explícito: todas visíveis após reload.
|
||||
- Cada faixa prova headers esperados, mudança compacta/completa e ausência de overflow.
|
||||
- Sidebar/chat são acionados pelos controles reais; o teste compara a largura medida e a etapa resultante.
|
||||
- Chromium desktop + WebKit focado; viewport iPad no cenário sticky/touch.
|
||||
|
||||
### Fonte oficial
|
||||
|
||||
- Tailwind CSS 4 container queries: `@container/{name}`, variantes nomeadas e valores arbitrários `@min-[…]`: https://tailwindcss.com/docs/responsive-design#container-queries
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não criar estado React, `ResizeObserver`, `matchMedia` ou hook de largura.
|
||||
- Não adicionar política responsiva genérica a `CampaignTable` nem alterar `ui/Table` globalmente.
|
||||
- Não desmontar a árvore cards/tabela com JavaScript; a montagem única é garantida entre coluna e `actions` na tabela.
|
||||
- Não otimizar loaders por coluna visível.
|
||||
- Não alterar conteúdo dos cards, o omnibox ou contratos de URL.
|
||||
- Não converter toda a suíte E2E para uma matriz cross-browser.
|
||||
- Não criar novos ícones/linguagem visual fora dos triggers adaptativos necessários.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Tailwind não emitir classes dinâmicas:** manter todas as variantes C0–C6 como strings literais completas no arquivo; typecheck e build comprovam geração.
|
||||
- **Head/cell divergirem:** usar um record local único por coluna e testes estáticos que verificam a mesma variante nos dois lados.
|
||||
- **Overflow por conteúdo real, especialmente relações:** testar nomes longos e várias relações; compactar somente trend/sinal; calibrar cortes em uma rodada medida, sem loop de microajustes.
|
||||
- **Sticky left em container sem scroller próprio:** testar Chromium/WebKit; manter fundo/z-index existentes e medir overflow do container.
|
||||
- **Sentinel vazar como coluna:** parser reconhece o valor antes da validação de IDs; serializer nunca o aceita como entrada de usuário; tests pinam que `hiddenColumnIds` recebe `[]`.
|
||||
- **Default novo parecer escolha explícita:** ausência aplica preset, mas a primeira interação escreve uma entrada explícita; “Restaurar todas” escreve sentinel e sobrevive a reload.
|
||||
- **Custo CI WebKit:** projeto restrito ao spec B158, não à suíte campaign inteira.
|
||||
- **Safari/iPad físico indisponível no ambiente Linux:** WebKit automatizado é gate técnico; inspeção física permanece checklist de lançamento, explicitamente não alegada como executada.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto.
|
||||
- [ ] Invariantes AGENTS/engineering-standards preservados; sem schema, DB, access ou Consent.
|
||||
- [ ] Preferência manual e capacidade do container permanecem dimensões independentes.
|
||||
- [ ] Cada controle Trend/Sinal é montado no máximo uma vez por linha dentro da tabela.
|
||||
- [ ] Testes unit/static cobrem cookie, papel, DOM e contratos URL/filtro.
|
||||
- [ ] E2E mede container, overflow, shell real e compatibilidade Chromium/WebKit.
|
||||
- [ ] Gate rápido, dead-code/cycles e push canônico previstos.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
1. Decisões caras têm alternativas rejeitadas: **1/1**.
|
||||
2. Abordagem cabe no appetite: **1/1**.
|
||||
3. Rabbit holes nomeados: **1/1**.
|
||||
4. Depth check reusa owners/shells/helpers: **1/1**.
|
||||
5. Outcome e lockdowns da intenção preservados: **1/1**.
|
||||
|
||||
**Total: 5/5.**
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { SearchXIcon } from 'lucide-react'
|
||||
import { ActivityIcon, CircleAlertIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {
|
||||
|
|
@ -69,6 +69,10 @@ import {
|
|||
resolveMunicipalityListSort,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
import {
|
||||
isMunicipalitySignalCold,
|
||||
municipalitySignalAgeInDays,
|
||||
} from '@/utilities/municipality/municipalitySignal'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
EligibleLeadershipOption,
|
||||
|
|
@ -155,8 +159,48 @@ const advisorEntries = (
|
|||
* Desktop table columns (Pass 2 W1). `id` is the picker key, cookie key, and B22
|
||||
* description key — typed instead of widening to `string`.
|
||||
*/
|
||||
type MunicipalityTableColumnId = MunicipalityListColumnId | 'actions'
|
||||
|
||||
type MunicipalityColumn = CampaignTableColumn<MunicipalityListViewModel> & {
|
||||
id: MunicipalityListColumnId
|
||||
id: MunicipalityTableColumnId
|
||||
}
|
||||
|
||||
const responsiveColumnClassName = {
|
||||
advisors: 'hidden @min-[54rem]/municipality-list:table-cell',
|
||||
leaderships: 'hidden @min-[66rem]/municipality-list:table-cell',
|
||||
stateDeputies: 'hidden @min-[72rem]/municipality-list:table-cell',
|
||||
goalCoverage: 'hidden @min-[78rem]/municipality-list:table-cell',
|
||||
} as const satisfies Partial<Record<MunicipalityTableColumnId, string>>
|
||||
|
||||
const MunicipalityListSignalTrigger = ({
|
||||
lastSignalAt,
|
||||
presentation,
|
||||
}: {
|
||||
lastSignalAt: string | null
|
||||
presentation: 'compact' | 'adaptive'
|
||||
}) => {
|
||||
const isCold = isMunicipalitySignalCold(municipalitySignalAgeInDays(lastSignalAt))
|
||||
const Icon = isCold ? CircleAlertIcon : ActivityIcon
|
||||
const compact = (
|
||||
<Icon
|
||||
className={cn(
|
||||
'size-4',
|
||||
isCold ? 'text-estimate-pending-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
|
||||
if (presentation === 'compact') return compact
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="@min-[84rem]/municipality-list:hidden">{compact}</span>
|
||||
<span className="hidden @min-[84rem]/municipality-list:block">
|
||||
<SignalAgeReadout lastSignalAt={lastSignalAt} layout="table" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const municipalityListColumns = ({
|
||||
|
|
@ -174,326 +218,373 @@ const municipalityListColumns = ({
|
|||
stateDeputyCommitAction,
|
||||
stateDeputyCreateAction,
|
||||
signalFormAction,
|
||||
}: MunicipalityListProps): Array<MunicipalityColumn> => [
|
||||
{
|
||||
id: 'name',
|
||||
label: municipalityColumnLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="name"
|
||||
filterParam="name"
|
||||
filterOptions={columnFilterOptions.name}
|
||||
showPriorityFilter={isStaffView}
|
||||
description={municipalityColumnDescriptions.name}
|
||||
className="sticky left-0 z-20 min-w-56 bg-background"
|
||||
columnVisibility,
|
||||
}: MunicipalityListProps): Array<MunicipalityColumn> => {
|
||||
const manuallyHidden = isStaffView ? new Set(columnVisibility.hiddenColumnIds) : new Set<string>()
|
||||
const trendIsHidden = manuallyHidden.has('trend')
|
||||
const signalIsHidden = manuallyHidden.has('lastSignal')
|
||||
|
||||
const renderTrendControl = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
triggerPresentation: 'compact' | 'adaptive',
|
||||
) => (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
variant="popover"
|
||||
triggerPresentation={triggerPresentation}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderSignalControl = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
triggerPresentation: 'compact' | 'adaptive',
|
||||
) => (
|
||||
<MunicipalityListSignalControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
municipalityName={municipality.name}
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
variant="popover"
|
||||
formAction={signalFormAction}
|
||||
>
|
||||
<MunicipalityListSignalTrigger
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
presentation={triggerPresentation}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'sticky left-0 z-[5] min-w-56 whitespace-normal bg-background',
|
||||
cell: (municipality) => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{municipality.priority === 'alta' && isStaffView ? <MunicipalityPriorityIndicator /> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
label: municipalityColumnLabels.region,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={columnFilterOptions.region}
|
||||
description={municipalityColumnDescriptions.region}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (municipality) => <TerritoryLink region={municipality.region} />,
|
||||
},
|
||||
{
|
||||
id: 'votos',
|
||||
label: municipalityColumnLabels.votos,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="votos"
|
||||
align="right"
|
||||
description={municipalityColumnDescriptions.votos}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) =>
|
||||
municipality.votePosition2022 ? (
|
||||
<MunicipalityVotePositionReadout position={municipality.votePosition2022} layout="table" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
</MunicipalityListSignalControl>
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
label: municipalityColumnLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="name"
|
||||
filterParam="name"
|
||||
filterOptions={columnFilterOptions.name}
|
||||
showPriorityFilter={isStaffView}
|
||||
description={municipalityColumnDescriptions.name}
|
||||
className="sticky left-0 z-20 w-52 max-w-52 whitespace-normal bg-background"
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(isStaffView
|
||||
? ([
|
||||
{
|
||||
// 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}
|
||||
sortKey="classe"
|
||||
filterParam="class"
|
||||
description={municipalityColumnDescriptions.classe}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
|
||||
// The factors behind the label — the cell keeps them as `sr-only`
|
||||
// text, so this stays a redundant affordance (see `cellTooltip`).
|
||||
cellTooltip: (municipality) =>
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors),
|
||||
},
|
||||
{
|
||||
// Right after "Classe": the pair reads diagnosis then decision.
|
||||
id: 'level',
|
||||
label: municipalityColumnLabels.level,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="nivel"
|
||||
filterParam="level"
|
||||
description={municipalityColumnDescriptions.level}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
variant="popover"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="table"
|
||||
cellClassName: 'sticky left-0 z-[5] w-52 max-w-52 whitespace-normal bg-background',
|
||||
cell: (municipality) => (
|
||||
<div className="flex min-w-0 max-w-52 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="line-clamp-2 min-h-11 min-w-0 flex-1 py-2 font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{municipality.priority === 'alta' && isStaffView ? (
|
||||
<span className="mt-2 shrink-0">
|
||||
<MunicipalityPriorityIndicator />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
<TerritoryLink region={municipality.region} />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'votos',
|
||||
label: municipalityColumnLabels.votos,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="votos"
|
||||
align="right"
|
||||
description={municipalityColumnDescriptions.votos}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) =>
|
||||
municipality.votePosition2022 ? (
|
||||
<MunicipalityVotePositionReadout
|
||||
position={municipality.votePosition2022}
|
||||
layout="table"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
...(isStaffView
|
||||
? ([
|
||||
{
|
||||
id: 'expectedVotes',
|
||||
label: municipalityColumnLabels.expectedVotes,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="expectedVotes"
|
||||
align="center"
|
||||
description={municipalityColumnDescriptions.expectedVotes}
|
||||
>
|
||||
2026
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cellClassName: 'relative overflow-visible align-middle text-center tabular-nums',
|
||||
cell: (municipality) => (
|
||||
<div className="flex min-h-11 items-center justify-center">
|
||||
<MunicipalityListExpectedVotesControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
variant="popover"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
// The declared investment follows the 2026 estimate: diagnosis and
|
||||
// decision stay together before the derived territorial class.
|
||||
id: 'level',
|
||||
label: municipalityColumnLabels.level,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="nivel"
|
||||
filterParam="level"
|
||||
description={municipalityColumnDescriptions.level}
|
||||
/>
|
||||
),
|
||||
// The editable cell wraps its own trigger in a tooltip; declaring it
|
||||
// here too would double it up on the same gesture (as in `advisors`).
|
||||
cellTooltip: (municipality) =>
|
||||
canMoveEngagementLevel || !municipality.engagementLevel
|
||||
? null
|
||||
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
},
|
||||
{
|
||||
id: 'advisors',
|
||||
label: municipalityColumnLabels.advisors,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
description={municipalityColumnDescriptions.advisors}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
cell: (municipality) =>
|
||||
canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
variant="popover"
|
||||
/>
|
||||
) : municipality.engagementLevel ? (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="table"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
// The editable cell wraps its own trigger in a tooltip; declaring it
|
||||
// here too would double it up on the same gesture (as in `advisors`).
|
||||
cellTooltip: (municipality) =>
|
||||
canMoveEngagementLevel || !municipality.engagementLevel
|
||||
? null
|
||||
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
},
|
||||
{
|
||||
id: 'classe',
|
||||
label: municipalityColumnLabels.classe,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="classe"
|
||||
filterParam="class"
|
||||
description={municipalityColumnDescriptions.classe}
|
||||
/>
|
||||
),
|
||||
// The coordinator's cell already wraps its own Popover trigger in a
|
||||
// tooltip (see `MunicipalityListAdvisorsControl`) — declaring it
|
||||
// here too would double it up on the same gesture.
|
||||
cellTooltip: (municipality) =>
|
||||
isCoordinator
|
||||
? null
|
||||
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
|
||||
},
|
||||
...(isCampaignUnrestricted
|
||||
? ([
|
||||
{
|
||||
// B157 — "who doubles with us here", next to "who takes care of
|
||||
// it": the advisors-style avatar stack + hover detail, with an
|
||||
// editor (Popover on desktop / Drawer on mobile) that also
|
||||
// creates dobradinhas inline. Coordinator + candidate only.
|
||||
id: 'stateDeputies',
|
||||
label: municipalityColumnLabels.stateDeputies,
|
||||
head: (
|
||||
<CampaignTableHead description={municipalityColumnDescriptions.stateDeputies}>
|
||||
{municipalityColumnLabels.stateDeputies}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (municipality) => (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
{
|
||||
// Right after "Assessores": the two relations of "who" in the
|
||||
// município sit together, and both are editable by all staff (the
|
||||
// advisor only within their portfolio, enforced server-side).
|
||||
id: 'leaderships',
|
||||
label: municipalityColumnLabels.leaderships,
|
||||
// No sort, no filter — the B22 explanation rides on the plain head.
|
||||
head: (
|
||||
<CampaignTableHead description={municipalityColumnDescriptions.leaderships}>
|
||||
{municipalityColumnLabels.leaderships}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListLeadershipsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentLeadershipIDs={municipality.leadershipIDs}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
options={leadershipOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
label: municipalityColumnLabels.trend,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="trend"
|
||||
filterParam="trend"
|
||||
description={municipalityColumnDescriptions.trend}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
variant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expectedVotes',
|
||||
label: municipalityColumnLabels.expectedVotes,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="expectedVotes"
|
||||
align="center"
|
||||
description={municipalityColumnDescriptions.expectedVotes}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'relative overflow-visible align-middle text-center',
|
||||
cell: (municipality) => (
|
||||
<div className="flex min-h-11 items-center justify-center">
|
||||
<MunicipalityListExpectedVotesControl
|
||||
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
|
||||
// The factors behind the label — the cell keeps them as `sr-only`
|
||||
// text, so this stays a redundant affordance (see `cellTooltip`).
|
||||
cellTooltip: (municipality) =>
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors),
|
||||
},
|
||||
{
|
||||
id: 'advisors',
|
||||
label: municipalityColumnLabels.advisors,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
description={municipalityColumnDescriptions.advisors}
|
||||
className={responsiveColumnClassName.advisors}
|
||||
/>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.advisors,
|
||||
cell: (municipality) =>
|
||||
isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
/>
|
||||
),
|
||||
// The coordinator's cell already wraps its own Popover trigger in a
|
||||
// tooltip (see `MunicipalityListAdvisorsControl`) — declaring it
|
||||
// here too would double it up on the same gesture.
|
||||
cellTooltip: (municipality) =>
|
||||
isCoordinator
|
||||
? null
|
||||
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
label: municipalityColumnLabels.trend,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="trend"
|
||||
filterParam="trend"
|
||||
description={municipalityColumnDescriptions.trend}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => renderTrendControl(municipality, 'adaptive'),
|
||||
},
|
||||
{
|
||||
id: 'leaderships',
|
||||
label: municipalityColumnLabels.leaderships,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={municipalityColumnDescriptions.leaderships}
|
||||
className={responsiveColumnClassName.leaderships}
|
||||
>
|
||||
{municipalityColumnLabels.leaderships}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.leaderships,
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListLeadershipsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
currentLeadershipIDs={municipality.leadershipIDs}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
options={leadershipOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSignal',
|
||||
label: municipalityColumnLabels.lastSignal,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="frescor"
|
||||
description={municipalityColumnDescriptions.lastSignal}
|
||||
>
|
||||
{/* 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) => (
|
||||
<MunicipalityListSignalControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
municipalityName={municipality.name}
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
variant="popover"
|
||||
formAction={signalFormAction}
|
||||
>
|
||||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
|
||||
</MunicipalityListSignalControl>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'goalCoverage',
|
||||
label: municipalityColumnLabels.goalCoverage,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="deficit"
|
||||
description={municipalityColumnDescriptions.goalCoverage}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: ([
|
||||
{
|
||||
id: 'lastUpdateAt',
|
||||
label: municipalityColumnLabels.lastUpdateAt,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="lastUpdateAt"
|
||||
description={municipalityColumnDescriptions.lastUpdateAt}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização',
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)),
|
||||
]
|
||||
),
|
||||
},
|
||||
...(isCampaignUnrestricted
|
||||
? ([
|
||||
{
|
||||
id: 'stateDeputies',
|
||||
label: municipalityColumnLabels.stateDeputies,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={municipalityColumnDescriptions.stateDeputies}
|
||||
className={responsiveColumnClassName.stateDeputies}
|
||||
>
|
||||
{municipalityColumnLabels.stateDeputies}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: cn(
|
||||
responsiveColumnClassName.stateDeputies,
|
||||
'max-w-56 whitespace-normal',
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
|
||||
{
|
||||
id: 'goalCoverage',
|
||||
label: municipalityColumnLabels.goalCoverage,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="deficit"
|
||||
description={municipalityColumnDescriptions.goalCoverage}
|
||||
className={responsiveColumnClassName.goalCoverage}
|
||||
/>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.goalCoverage,
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSignal',
|
||||
label: municipalityColumnLabels.lastSignal,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="frescor"
|
||||
description={municipalityColumnDescriptions.lastSignal}
|
||||
>
|
||||
{municipalityColumnLabels.lastSignal}
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => renderSignalControl(municipality, 'adaptive'),
|
||||
},
|
||||
...(trendIsHidden || signalIsHidden
|
||||
? ([
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<CampaignTableHead align="right">
|
||||
<span className="sr-only">Ações</span>
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{trendIsHidden ? renderTrendControl(municipality, 'compact') : null}
|
||||
{signalIsHidden ? renderSignalControl(municipality, 'compact') : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: ([
|
||||
{
|
||||
id: 'lastUpdateAt',
|
||||
label: municipalityColumnLabels.lastUpdateAt,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="lastUpdateAt"
|
||||
description={municipalityColumnDescriptions.lastUpdateAt}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização',
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)),
|
||||
]
|
||||
}
|
||||
|
||||
export const municipalityListPickerColumns = ({
|
||||
isStaffView,
|
||||
|
|
@ -504,25 +595,23 @@ export const municipalityListPickerColumns = ({
|
|||
>): CampaignColumnPickerColumn[] => {
|
||||
const base: CampaignColumnPickerColumn[] = [
|
||||
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
|
||||
{ id: 'region', label: municipalityColumnLabels.region },
|
||||
{ id: 'votos', label: municipalityColumnLabels.votos },
|
||||
]
|
||||
if (!isStaffView) return base
|
||||
|
||||
return [
|
||||
...base,
|
||||
{ id: 'classe', label: municipalityColumnLabels.classe },
|
||||
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
|
||||
{ id: 'level', label: municipalityColumnLabels.level },
|
||||
{ id: 'classe', label: municipalityColumnLabels.classe },
|
||||
{ id: 'advisors', label: municipalityColumnLabels.advisors },
|
||||
{ id: 'trend', label: municipalityColumnLabels.trend },
|
||||
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
|
||||
...(isCampaignUnrestricted
|
||||
? [{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies }]
|
||||
: []),
|
||||
{ id: 'trend', label: municipalityColumnLabels.trend },
|
||||
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
|
||||
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
|
||||
{ id: 'goalCoverage', label: municipalityColumnLabels.goalCoverage },
|
||||
{ id: 'lastUpdateAt', label: municipalityColumnLabels.lastUpdateAt },
|
||||
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -554,40 +643,42 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
return (
|
||||
<MunicipalityAdvisorCreateProvider>
|
||||
<MunicipalityLeadershipCreateProvider>
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
isCampaignUnrestricted={isCampaignUnrestricted}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
stateDeputyOptions={stateDeputyOptions}
|
||||
stateDeputyCommitAction={stateDeputyCommitAction}
|
||||
stateDeputyCreateAction={stateDeputyCreateAction}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
<div data-container="municipality-list" className="@container/municipality-list">
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
isCampaignUnrestricted={isCampaignUnrestricted}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
stateDeputyOptions={stateDeputyOptions}
|
||||
stateDeputyCommitAction={stateDeputyCommitAction}
|
||||
stateDeputyCreateAction={stateDeputyCreateAction}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
|
||||
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
|
||||
<CampaignTable
|
||||
className="hidden overflow-visible md:block"
|
||||
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"
|
||||
caption={
|
||||
<>
|
||||
{sortSummary}
|
||||
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
columnVisibility={props.columnVisibility}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
|
||||
<CampaignTable
|
||||
className="hidden overflow-visible @min-[48rem]/municipality-list:block"
|
||||
containerClassName="overflow-x-auto supports-[container-type:inline-size]:overflow-x-hidden"
|
||||
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}
|
||||
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
columnVisibility={props.columnVisibility}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
</div>
|
||||
</MunicipalityLeadershipCreateProvider>
|
||||
</MunicipalityAdvisorCreateProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -248,12 +248,17 @@ export const MunicipalityListLevelControl = ({
|
|||
preventPopoverAutoFocus
|
||||
trigger={
|
||||
// The sheet has no hover to carry the rest, so the trigger spells the
|
||||
// level out there and stays the bare numeral in the table.
|
||||
<MunicipalityLevelBadge
|
||||
level={currentLevel}
|
||||
note={isSheet ? currentNote : null}
|
||||
layout={isSheet ? 'card' : 'table'}
|
||||
/>
|
||||
// level out there. In the table, absence follows the shared cell pattern
|
||||
// (`—`) while the button's aria-label still announces "Sem nível".
|
||||
currentLevel || isSheet ? (
|
||||
<MunicipalityLevelBadge
|
||||
level={currentLevel}
|
||||
note={isSheet ? currentNote : null}
|
||||
layout={isSheet ? 'card' : 'table'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
|
|||
|
|
@ -83,7 +83,10 @@ export const MunicipalityListMobileCards = ({
|
|||
signalFormAction,
|
||||
emptySlot,
|
||||
}: MunicipalityListMobileCardsProps) => (
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
<div
|
||||
data-view="mobile-cards"
|
||||
className="flex flex-col gap-4 @min-[48rem]/municipality-list:hidden"
|
||||
>
|
||||
{municipalities.length === 0 ? emptySlot : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map(
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export const MunicipalityListSignalControl = ({
|
|||
triggerLabel={`Registrar sinal em ${municipalityName} — ${frescorLabel}`}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={isPending ? 'Registrando sinal…' : ''}
|
||||
triggerClassName="text-left"
|
||||
triggerClassName="min-w-11 text-left"
|
||||
contentClassName="w-80"
|
||||
trigger={children}
|
||||
footer={
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { CircleHelpIcon, MinusIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type {
|
||||
|
|
@ -45,14 +46,22 @@ type MunicipalityListTrendControlProps = {
|
|||
status: MunicipalityListSavedPoliticalTrend['status']
|
||||
trendNote: string | null
|
||||
variant: CampaignCellEditOverlayVariant
|
||||
triggerPresentation?: 'full' | 'compact' | 'adaptive'
|
||||
}
|
||||
|
||||
const politicalTrendIcon = {
|
||||
favoravel: TrendingUpIcon,
|
||||
neutra: MinusIcon,
|
||||
desfavoravel: TrendingDownIcon,
|
||||
} as const
|
||||
|
||||
export const MunicipalityListTrendControl = ({
|
||||
municipalityID,
|
||||
municipalityName,
|
||||
status,
|
||||
trendNote,
|
||||
variant,
|
||||
triggerPresentation = 'full',
|
||||
}: MunicipalityListTrendControlProps) => {
|
||||
const { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage } =
|
||||
useCampaignCellAutosave<
|
||||
|
|
@ -88,6 +97,25 @@ export const MunicipalityListTrendControl = ({
|
|||
|
||||
const hasNote = Boolean(value.note)
|
||||
const trendLabel = value.status ? politicalTrendLabels[value.status] : 'Não registrada'
|
||||
const TrendIcon = value.status ? politicalTrendIcon[value.status] : CircleHelpIcon
|
||||
const badgeVariant = value.status ? politicalTrendBadgeVariant[value.status] : 'outline'
|
||||
const fullTrigger = <Badge variant={badgeVariant}>{trendLabel}</Badge>
|
||||
const compactTrigger = (
|
||||
<Badge variant={badgeVariant} className="size-7 justify-center p-0">
|
||||
<TrendIcon className="size-4" aria-hidden="true" />
|
||||
</Badge>
|
||||
)
|
||||
const trigger =
|
||||
triggerPresentation === 'full' ? (
|
||||
fullTrigger
|
||||
) : triggerPresentation === 'compact' ? (
|
||||
compactTrigger
|
||||
) : (
|
||||
<>
|
||||
<span className="@min-[60rem]/municipality-list:hidden">{compactTrigger}</span>
|
||||
<span className="hidden @min-[60rem]/municipality-list:inline-flex">{fullTrigger}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
|
|
@ -102,17 +130,12 @@ export const MunicipalityListTrendControl = ({
|
|||
// `MunicipalityListSignalControl`'s.
|
||||
triggerLabel={`Editar tendência política em ${municipalityName} — ${trendLabel}`}
|
||||
triggerBusy={isPending}
|
||||
triggerClassName={triggerPresentation === 'full' ? undefined : 'min-w-11'}
|
||||
statusMessage={statusMessage}
|
||||
tooltipContent={hasNote ? <p className="whitespace-pre-wrap">{value.note}</p> : null}
|
||||
contentClassName="w-72 p-3"
|
||||
preventPopoverAutoFocus
|
||||
trigger={
|
||||
value.status ? (
|
||||
<Badge variant={politicalTrendBadgeVariant[value.status]}>{trendLabel}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{trendLabel}</Badge>
|
||||
)
|
||||
}
|
||||
trigger={trigger}
|
||||
>
|
||||
<div className="relative flex flex-col gap-3">
|
||||
{isPending ? (
|
||||
|
|
|
|||
|
|
@ -63,8 +63,13 @@ export const toCampaignColumnPickerColumns = (
|
|||
const LIST_SEPARATOR = '|'
|
||||
const LIST_ID_SEPARATOR = ':'
|
||||
const COLUMN_SEPARATOR = '~'
|
||||
const EXPLICIT_EMPTY_COLUMNS = '__none__'
|
||||
const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
const DEFAULT_HIDDEN_COLUMN_IDS: Partial<Record<CampaignListId, readonly string[]>> = {
|
||||
municipios: ['goalCoverage', 'lastSignal'],
|
||||
}
|
||||
|
||||
const isCampaignListId = (value: string): value is CampaignListId =>
|
||||
CAMPAIGN_LIST_IDS.some((listId) => listId === value)
|
||||
|
||||
|
|
@ -83,12 +88,19 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid
|
|||
// 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 encodedColumns = entry.slice(separatorIndex + 1)
|
||||
if (encodedColumns === EXPLICIT_EMPTY_COLUMNS) {
|
||||
parsed[listId] = []
|
||||
continue
|
||||
}
|
||||
|
||||
const columnIds = [
|
||||
...new Set(
|
||||
entry
|
||||
.slice(separatorIndex + 1)
|
||||
encodedColumns
|
||||
.split(COLUMN_SEPARATOR)
|
||||
.filter((columnId) => COLUMN_ID_PATTERN.test(columnId)),
|
||||
.filter(
|
||||
(columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -100,11 +112,27 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid
|
|||
|
||||
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)}`]
|
||||
if (!Object.prototype.hasOwnProperty.call(value, listId)) return []
|
||||
|
||||
const columnIds = (value[listId] ?? []).filter(
|
||||
(columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId),
|
||||
)
|
||||
const encodedColumns = columnIds.length
|
||||
? columnIds.join(COLUMN_SEPARATOR)
|
||||
: EXPLICIT_EMPTY_COLUMNS
|
||||
return [`${listId}${LIST_ID_SEPARATOR}${encodedColumns}`]
|
||||
}).join(LIST_SEPARATOR)
|
||||
|
||||
export const resolveCampaignColumnVisibility = (
|
||||
listId: CampaignListId,
|
||||
hiddenColumns: CampaignHiddenColumns,
|
||||
): CampaignColumnVisibility => ({
|
||||
listId,
|
||||
hiddenColumnIds: Object.prototype.hasOwnProperty.call(hiddenColumns, listId)
|
||||
? (hiddenColumns[listId] ?? [])
|
||||
: (DEFAULT_HIDDEN_COLUMN_IDS[listId] ?? []),
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { cookies } from 'next/headers'
|
|||
import {
|
||||
CAMPAIGN_COLUMNS_COOKIE,
|
||||
parseCampaignHiddenColumns,
|
||||
resolveCampaignColumnVisibility,
|
||||
type CampaignColumnVisibility,
|
||||
type CampaignListId,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
|
|
@ -20,5 +21,5 @@ export const readCampaignColumnVisibility = async (
|
|||
const cookieStore = await cookies()
|
||||
const raw = cookieStore.get(CAMPAIGN_COLUMNS_COOKIE)?.value
|
||||
|
||||
return { listId, hiddenColumnIds: parseCampaignHiddenColumns(raw)[listId] ?? [] }
|
||||
return resolveCampaignColumnVisibility(listId, parseCampaignHiddenColumns(raw))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,6 @@ export const formatMunicipalityGeographyLabel = (municipality: {
|
|||
*/
|
||||
export type MunicipalityListColumnId =
|
||||
| 'name'
|
||||
| 'region'
|
||||
| 'votos'
|
||||
| 'classe'
|
||||
// Matches the column's own `id` and its `?level=` filter param; the SORT key
|
||||
|
|
@ -214,8 +213,7 @@ const CLASS_COLUMN_DESCRIPTION =
|
|||
* on props, so there is nothing to recompute per render.
|
||||
*/
|
||||
export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, string> = {
|
||||
name: 'Nome do município e prioridade da campanha.',
|
||||
region: 'Território de Identidade (Bahia) a que o município pertence.',
|
||||
name: 'Nome do município, Território de Identidade e prioridade da campanha.',
|
||||
votos: formatMunicipalityConcentrationHint(),
|
||||
classe: CLASS_COLUMN_DESCRIPTION,
|
||||
level: campaignConceptOneLiner('nivel-de-envolvimento'),
|
||||
|
|
@ -224,7 +222,8 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
|
|||
stateDeputies:
|
||||
'Deputados estaduais com quem a campanha dobra neste município — coordenação e candidato editam clicando aqui.',
|
||||
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
|
||||
expectedVotes: campaignConceptOneLiner('meta'),
|
||||
expectedVotes:
|
||||
'Estimativa de votos do candidato neste município em 2026. Ordena pelo cenário central, independente do cenário selecionado acima.',
|
||||
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,
|
||||
// The scenario picker above the table is client state, so the server can
|
||||
// only sort by one scenario — named here so the ordering never looks
|
||||
|
|
|
|||
|
|
@ -101,21 +101,18 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
*/
|
||||
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
|
||||
name: municipalityListSortLabels.name,
|
||||
region: municipalityListSortLabels.region,
|
||||
/** Header is the bare year, under the "2022" group of the table. */
|
||||
votos: 'Votação 2022',
|
||||
classe: municipalityListSortLabels.classe,
|
||||
expectedVotes: 'Estimativa 2026',
|
||||
level: municipalityListSortLabels.nivel,
|
||||
advisors: municipalityListSortLabels.coverage,
|
||||
/** B155 — the column has no sort/filter; the label stands alone in the picker. */
|
||||
leaderships: 'Lideranças',
|
||||
/** B157 — nova coluna entre Assessores e Tendência (grupo "rede"). */
|
||||
stateDeputies: 'Dobradinhas',
|
||||
classe: municipalityListSortLabels.classe,
|
||||
advisors: 'Assessor',
|
||||
trend: municipalityListSortLabels.trend,
|
||||
expectedVotes: municipalityListSortLabels.expectedVotes,
|
||||
/** The column shows the signal; `frescor` sorts by how old it is. */
|
||||
lastSignal: 'Último sinal',
|
||||
leaderships: 'Liderança',
|
||||
stateDeputies: 'Dobradinha',
|
||||
goalCoverage: municipalityListSortLabels.deficit,
|
||||
/** The column shows the signal; `frescor` sorts by how old it is. */
|
||||
lastSignal: 'Sinal',
|
||||
lastUpdateAt: municipalityListSortLabels.lastUpdateAt,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ test.describe('Seletor de colunas', () => {
|
|||
// Crosses two heavy list routes; `/campanha/liderancas` only finishes
|
||||
// compiling in dev when an authenticated request reaches its tree.
|
||||
test.slow()
|
||||
// B158 switches to cards below 48rem of content width. Give this picker
|
||||
// regression enough content room to observe table headers directly.
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
|
|
@ -36,20 +39,33 @@ test.describe('Seletor de colunas', () => {
|
|||
await campaign.login(page, email, password)
|
||||
await page.goto('/campanha/municipios')
|
||||
|
||||
const regionHeader = page.getByRole('columnheader', { name: /Território/ })
|
||||
await expect(regionHeader).toBeVisible()
|
||||
const votesHeader = page.getByRole('columnheader', { name: /2022/ })
|
||||
await expect(votesHeader).toBeVisible()
|
||||
|
||||
const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
|
||||
await picker.click()
|
||||
await page.getByRole('checkbox', { name: 'Território' }).click()
|
||||
// A fresh profile receives B158's two defaults. Restoring all records the
|
||||
// deliberate empty preference instead of reverting to those defaults.
|
||||
await page.getByRole('button', { name: 'Restaurar todas' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await page.context().cookies()).find((cookie) => cookie.name === 'campaign_columns')
|
||||
?.value,
|
||||
)
|
||||
.toContain('municipios:__none__')
|
||||
|
||||
await picker.click()
|
||||
await page.getByRole('checkbox', { name: 'Votação 2022' }).click()
|
||||
// Closing flushes the batched cookie write and refreshes the RSC.
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(regionHeader).toHaveCount(0, REFRESH)
|
||||
await expect(votesHeader).toHaveCount(0, REFRESH)
|
||||
await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('columnheader', { name: /Território/ })).toHaveCount(0, REFRESH)
|
||||
await expect(page.getByRole('columnheader', { name: /2022/ })).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
|
||||
|
|
@ -63,7 +79,16 @@ test.describe('Seletor de colunas', () => {
|
|||
await page.getByRole('button', { name: 'Restaurar todas' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: /Território/ })).toBeVisible(REFRESH)
|
||||
await expect(page.getByRole('columnheader', { name: /2022/ })).toBeVisible(REFRESH)
|
||||
await page.reload()
|
||||
await expect(page.getByRole('columnheader', { name: /2022/ })).toBeVisible(REFRESH)
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await page.context().cookies()).find((cookie) => cookie.name === 'campaign_columns')
|
||||
?.value,
|
||||
)
|
||||
.toContain('municipios:__none__')
|
||||
})
|
||||
|
||||
test('lists the mandatory column without letting it be unchecked', async ({ campaign, page }) => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
|
||||
import {
|
||||
ensureLeasedConsent,
|
||||
|
|
@ -15,6 +17,39 @@ import {
|
|||
collectActionBoundingBoxes,
|
||||
} from './helpers/actionGridGeometry.js'
|
||||
|
||||
const showAllMunicipalityColumns = (page: Page, url: string) =>
|
||||
page.context().addCookies([
|
||||
{
|
||||
name: 'campaign_columns',
|
||||
value: encodeURIComponent('municipios:__none__'),
|
||||
url,
|
||||
},
|
||||
])
|
||||
|
||||
const municipalityContainer = (page: Page) =>
|
||||
page.locator('[data-container="municipality-list"]').last()
|
||||
|
||||
const visibleMunicipalityButton = (page: Page, name: string) =>
|
||||
municipalityContainer(page).getByRole('button', { name }).filter({ visible: true })
|
||||
|
||||
const ensureWideMunicipalityList = async (page: Page) => {
|
||||
// The resizable panel hydrates independently from the RSC list and can
|
||||
// change the container stage after a cell first becomes visible. These
|
||||
// desktop cell journeys wait for the widest table stage; B158 exercises the
|
||||
// rail transition itself in its dedicated spec.
|
||||
await page.waitForLoadState('networkidle')
|
||||
const container = municipalityContainer(page)
|
||||
const width = await container.evaluate((element) => element.clientWidth)
|
||||
const close = page.getByRole('button', { name: 'Fechar', exact: true }).filter({ visible: true })
|
||||
// A collapsed Panel can leave its overflowing close button technically
|
||||
// visible. Only click when the list itself proves the chat still consumes
|
||||
// space; otherwise the click is a no-op against an already collapsed rail.
|
||||
if (width < 78 * 16 && (await close.isVisible())) await close.click()
|
||||
await expect
|
||||
.poll(() => container.evaluate((element) => element.clientWidth))
|
||||
.toBeGreaterThanOrEqual(78 * 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Core municipality-model journeys per role: coordinator strategy editing, advisor
|
||||
* scoping, staff declare/estimate privacy boundary, leader lockdown, and staff-only
|
||||
|
|
@ -26,6 +61,14 @@ import {
|
|||
*/
|
||||
|
||||
test.describe('Municípios — jornadas por papel', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// B158 keys the table stages to content width and gives fresh profiles a
|
||||
// compact default. These pre-B158 journeys exercise specific desktop
|
||||
// cells, so make that prerequisite explicit instead of finding hidden
|
||||
// copies in the always-mounted mobile-card tree.
|
||||
await page.setViewportSize({ width: 2400, height: 1000 })
|
||||
})
|
||||
|
||||
test('coordinator opens the municipalities list, edits strategy and assigns an advisor', async ({
|
||||
campaign,
|
||||
page,
|
||||
|
|
@ -41,6 +84,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
|
||||
|
|
@ -96,16 +140,23 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await ensureWideMunicipalityList(page)
|
||||
|
||||
// Name includes the município — a substring search can return neighbors
|
||||
// (strict-mode flake when two "Editar assessores …" buttons match).
|
||||
const advisorsTrigger = page.getByRole('button', {
|
||||
name: `Editar assessores em ${municipality.name}`,
|
||||
})
|
||||
// A concurrent RSC navigation may briefly retain the previous segment.
|
||||
// Scope the editor to the appended, active municipality container so a
|
||||
// hidden copy cannot absorb Playwright's click retries until timeout.
|
||||
const advisorsTrigger = visibleMunicipalityButton(
|
||||
page,
|
||||
`Editar assessores em ${municipality.name}`,
|
||||
)
|
||||
await expect(advisorsTrigger).toBeVisible()
|
||||
await advisorsTrigger.click()
|
||||
|
||||
const advisorsPopover = page.locator('[data-slot="popover-content"]')
|
||||
|
|
@ -117,16 +168,12 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// No "Salvar" button in this popover: selecting the option auto-saves the
|
||||
// delta and renders it as a removable chip immediately.
|
||||
const chip = advisorsPopover.getByRole('button', { name: `Remover ${advisor.name}` })
|
||||
// Click the option itself: the inner text generic does not always trigger
|
||||
// the combobox selection (measured — the popover stayed on "selected: você").
|
||||
// Retry loop for the pre-hydration flake class (same as
|
||||
// `checkRadixWhenHydrated`): a click before React attaches is a silent
|
||||
// no-op, so the probe retries until the chip sticks.
|
||||
await expect(async () => {
|
||||
await search.fill(searchNamePart)
|
||||
await option.click({ timeout: 1_000 })
|
||||
await expect(chip).toBeVisible({ timeout: 4_000 })
|
||||
}).toPass({ timeout: 20_000 })
|
||||
// Opening this client-only popover already proves hydration. Wait for the
|
||||
// write itself: the chip and trigger are optimistic and do not prove the
|
||||
// assignment reached the database before the reload below.
|
||||
await search.fill(searchNamePart)
|
||||
await Promise.all([expectPostResponse(page, '/campanha/municipios/advisors'), option.click()])
|
||||
await expect(chip).toBeVisible()
|
||||
|
||||
// The popover stays open after the write (auto-save, not submit+close).
|
||||
await expect(advisorsPopover).toBeVisible()
|
||||
|
|
@ -143,11 +190,11 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// state, now holds the assignment.
|
||||
await page.reload()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `Editar assessores em ${municipality.name} — ${advisor.name}`,
|
||||
})
|
||||
.click()
|
||||
await ensureWideMunicipalityList(page)
|
||||
await visibleMunicipalityButton(
|
||||
page,
|
||||
`Editar assessores em ${municipality.name} — ${advisor.name}`,
|
||||
).click()
|
||||
await expect(
|
||||
page.locator('[data-slot="popover-content"]').getByRole('button', {
|
||||
name: `Remover ${advisor.name}`,
|
||||
|
|
@ -170,14 +217,17 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
const newAdvisorName = fixtures.value('Novo Assessor')
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await ensureWideMunicipalityList(page)
|
||||
|
||||
const advisorsTrigger = page.getByRole('button', {
|
||||
name: `Editar assessores em ${municipality.name}`,
|
||||
})
|
||||
const advisorsTrigger = visibleMunicipalityButton(
|
||||
page,
|
||||
`Editar assessores em ${municipality.name}`,
|
||||
)
|
||||
await advisorsTrigger.click()
|
||||
const advisorsPopover = page.locator('[data-slot="popover-content"]')
|
||||
await expect(advisorsPopover).toBeVisible()
|
||||
|
|
@ -213,11 +263,11 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// and the assignment, not just the local optimistic state.
|
||||
await page.reload()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `Editar assessores em ${municipality.name} — ${newAdvisorName}`,
|
||||
})
|
||||
.click()
|
||||
await ensureWideMunicipalityList(page)
|
||||
await visibleMunicipalityButton(
|
||||
page,
|
||||
`Editar assessores em ${municipality.name} — ${newAdvisorName}`,
|
||||
).click()
|
||||
await expect(
|
||||
page.locator('[data-slot="popover-content"]').getByRole('button', {
|
||||
name: `Remover ${newAdvisorName}`,
|
||||
|
|
@ -241,14 +291,17 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
const party = 'PCdoB'
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await ensureWideMunicipalityList(page)
|
||||
|
||||
const deputiesTrigger = page.getByRole('button', {
|
||||
name: `Editar dobradinhas em ${municipality.name}`,
|
||||
})
|
||||
const deputiesTrigger = visibleMunicipalityButton(
|
||||
page,
|
||||
`Editar dobradinhas em ${municipality.name}`,
|
||||
)
|
||||
await deputiesTrigger.click()
|
||||
const deputiesPopover = page.locator('[data-slot="popover-content"]')
|
||||
await expect(deputiesPopover).toBeVisible()
|
||||
|
|
@ -286,7 +339,8 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// the assignment, not just the local optimistic state.
|
||||
await page.reload()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await page.getByRole('button', { name: `Editar dobradinhas em ${municipality.name}` }).click()
|
||||
await ensureWideMunicipalityList(page)
|
||||
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
|
||||
await expect(
|
||||
page.locator('[data-slot="popover-content"]').getByRole('button', {
|
||||
name: `Remover ${deputyName} (${party})`,
|
||||
|
|
@ -307,6 +361,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
const note = fixtures.value('Vereador confirmou apoio local')
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
|
|
@ -349,18 +404,22 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await ensureWideMunicipalityList(page)
|
||||
|
||||
// Anchored: a bare template regex on a municipality name collides with
|
||||
// prefix-shared names (Conde/Condeúba — 23/435 measured in the catalog),
|
||||
// and `exact` no longer matches because the accessible name appends the
|
||||
// current signal state ("— Sem sinal").
|
||||
const signalTrigger = page.getByRole('button', {
|
||||
name: new RegExp(`^Registrar sinal em ${municipality.name} —`),
|
||||
})
|
||||
const signalTrigger = municipalityContainer(page)
|
||||
.getByRole('button', {
|
||||
name: new RegExp(`^Registrar sinal em ${municipality.name} —`),
|
||||
})
|
||||
.filter({ visible: true })
|
||||
await expect(signalTrigger).toBeVisible()
|
||||
await signalTrigger.click()
|
||||
|
||||
|
|
@ -370,9 +429,13 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
.getByRole('textbox', { name: 'Texto' })
|
||||
.fill('Liderança local reportou visita adversária na feira.')
|
||||
await signalForm.getByLabel('Tipo do sinal').selectOption('visita_adversario')
|
||||
await signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click()
|
||||
await Promise.all([
|
||||
expectPostResponse(page, '/campanha/municipios'),
|
||||
signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click(),
|
||||
])
|
||||
|
||||
await expect(page.getByText('Sinal registrado.', { exact: true })).toBeVisible()
|
||||
// The toast is intentionally transient; the persisted freshness label is
|
||||
// the stable success contract and must arrive after the server response.
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: `Registrar sinal em ${municipality.name} — hoje`,
|
||||
|
|
@ -433,6 +496,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
|
||||
// Advisor scope: only the administered municipality shows up.
|
||||
await campaign.login(page, advisor.email!, password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
await expect(
|
||||
page.getByRole('link', { name: administered.name, exact: true }).first(),
|
||||
|
|
|
|||
248
tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts
Normal file
248
tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
const REM_IN_PIXELS = 16
|
||||
const municipalityColumnsCookie = encodeURIComponent('municipios:__none__')
|
||||
|
||||
// During a concurrent RSC navigation Next may briefly retain the previous
|
||||
// segment beside the incoming one. The current segment is appended last.
|
||||
const municipalityContainer = (page: Page) =>
|
||||
page.locator('[data-container="municipality-list"]').last()
|
||||
|
||||
const setContainerWidth = async (container: Locator, width: number) => {
|
||||
await container.evaluate((element, nextWidth) => {
|
||||
const htmlElement = element as HTMLElement
|
||||
htmlElement.style.width = `${nextWidth}px`
|
||||
htmlElement.style.maxWidth = `${nextWidth}px`
|
||||
}, width)
|
||||
await expect.poll(() => container.evaluate((element) => element.clientWidth)).toBe(width)
|
||||
}
|
||||
|
||||
const visibleTriggerText = (trigger: Locator) =>
|
||||
trigger.evaluate((element) => (element as HTMLElement).innerText.trim())
|
||||
|
||||
const visibleHeaders = (container: Locator) =>
|
||||
container
|
||||
.locator('thead th')
|
||||
.evaluateAll((headers) =>
|
||||
headers
|
||||
.filter((header) => getComputedStyle(header).display !== 'none')
|
||||
.map((header) => (header.textContent ?? '').replaceAll(/\s+/g, ' ').trim()),
|
||||
)
|
||||
|
||||
const expectNoHorizontalOverflow = async (container: Locator) => {
|
||||
await expect
|
||||
.poll(() => container.evaluate((element) => element.scrollWidth <= element.clientWidth))
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
type ResponsiveCampaignFixture = {
|
||||
fixtures: {
|
||||
createCampaignUser: (
|
||||
role: 'advisor' | 'coordinator',
|
||||
input: { name: string },
|
||||
) => Promise<{ email?: string | null; password: string }>
|
||||
value: (prefix: string) => string
|
||||
}
|
||||
login: (page: Page, identifier: string, password: string) => Promise<void>
|
||||
}
|
||||
|
||||
const loginWithAllColumns = async (
|
||||
page: Page,
|
||||
campaign: ResponsiveCampaignFixture,
|
||||
role: 'advisor' | 'coordinator' = 'coordinator',
|
||||
) => {
|
||||
const user = await campaign.fixtures.createCampaignUser(role, {
|
||||
name: campaign.fixtures.value(`Responsivo ${role}`),
|
||||
})
|
||||
await campaign.login(page, user.email!, user.password)
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'campaign_columns',
|
||||
value: municipalityColumnsCookie,
|
||||
url: new URL(page.url()).origin,
|
||||
},
|
||||
])
|
||||
await page.goto('/campanha/municipios')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(municipalityContainer(page)).toBeVisible()
|
||||
}
|
||||
|
||||
const expectedHeadersAt = (width: number) => {
|
||||
if (width < 48 * REM_IN_PIXELS) return []
|
||||
|
||||
const headers = ['Município', '2022', '2026', 'Nível', 'Classe']
|
||||
if (width >= 54 * REM_IN_PIXELS) headers.push('Assessores')
|
||||
headers.push('Tendência')
|
||||
if (width >= 66 * REM_IN_PIXELS) headers.push('Liderança')
|
||||
if (width >= 72 * REM_IN_PIXELS) headers.push('Dobradinha')
|
||||
if (width >= 78 * REM_IN_PIXELS) headers.push('Cobertura')
|
||||
headers.push('Sinal')
|
||||
return headers
|
||||
}
|
||||
|
||||
test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
|
||||
test('crosses every container threshold without horizontal scrolling', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await loginWithAllColumns(page, campaign)
|
||||
const container = municipalityContainer(page)
|
||||
const cards = container.locator('[data-view="mobile-cards"]')
|
||||
const table = container.getByRole('table')
|
||||
const trendTrigger = table.getByRole('button', { name: /^Editar tendência política/ }).first()
|
||||
const signalTrigger = table.getByRole('button', { name: /^Registrar sinal em/ }).first()
|
||||
// Test around (and on) the three binary switches: cards↔table (48rem),
|
||||
// trend text (60rem), signal text (84rem). Container queries use min-width.
|
||||
const widths = [
|
||||
48 * REM_IN_PIXELS - 1,
|
||||
48 * REM_IN_PIXELS,
|
||||
48 * REM_IN_PIXELS + 1,
|
||||
54 * REM_IN_PIXELS - 1,
|
||||
54 * REM_IN_PIXELS + 1,
|
||||
60 * REM_IN_PIXELS - 1,
|
||||
60 * REM_IN_PIXELS,
|
||||
60 * REM_IN_PIXELS + 1,
|
||||
66 * REM_IN_PIXELS - 1,
|
||||
66 * REM_IN_PIXELS + 1,
|
||||
72 * REM_IN_PIXELS - 1,
|
||||
72 * REM_IN_PIXELS + 1,
|
||||
78 * REM_IN_PIXELS - 1,
|
||||
78 * REM_IN_PIXELS + 1,
|
||||
84 * REM_IN_PIXELS - 1,
|
||||
84 * REM_IN_PIXELS,
|
||||
84 * REM_IN_PIXELS + 1,
|
||||
]
|
||||
|
||||
for (const width of widths) {
|
||||
await test.step(`${width}px`, async () => {
|
||||
await setContainerWidth(container, width)
|
||||
|
||||
if (width < 48 * REM_IN_PIXELS) {
|
||||
await expect(cards).toBeVisible()
|
||||
await expect(table).toBeHidden()
|
||||
} else {
|
||||
await expect(cards).toBeHidden()
|
||||
await expect(table).toBeVisible()
|
||||
expect(await visibleHeaders(container)).toEqual(expectedHeadersAt(width))
|
||||
expect(await visibleTriggerText(trendTrigger)).toBe(
|
||||
width >= 60 * REM_IN_PIXELS ? 'Não registrada' : '',
|
||||
)
|
||||
expect(await visibleTriggerText(signalTrigger)).toBe(
|
||||
width >= 84 * REM_IN_PIXELS ? 'Sem sinal' : '',
|
||||
)
|
||||
}
|
||||
|
||||
await expectNoHorizontalOverflow(container)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('sidebar and Sollinha change the column stage without changing the viewport', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
// At 1600px the real desktop rails straddle the responsive stages: the
|
||||
// collapsed sidebar adds a column and the open chat removes one. A wider
|
||||
// viewport can leave all optional columns visible in both states, which
|
||||
// would only prove that the container shrank, not that its query reacted.
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await loginWithAllColumns(page, campaign)
|
||||
const container = municipalityContainer(page)
|
||||
const viewportBefore = page.viewportSize()
|
||||
const widthWithSidebar = await container.evaluate((element) => element.clientWidth)
|
||||
const headersWithSidebar = await visibleHeaders(container)
|
||||
|
||||
await page.getByRole('button', { name: 'Abrir ou fechar menu da campanha' }).click()
|
||||
await expect
|
||||
.poll(() => container.evaluate((element) => element.clientWidth))
|
||||
.toBeGreaterThan(widthWithSidebar)
|
||||
await expect
|
||||
.poll(async () => (await visibleHeaders(container)).length)
|
||||
.toBeGreaterThan(headersWithSidebar.length)
|
||||
const widthAfterSidebarToggle = await container.evaluate((element) => element.clientWidth)
|
||||
const initiallyOpenChatClose = page
|
||||
.getByRole('button', { name: 'Fechar', exact: true })
|
||||
.filter({ visible: true })
|
||||
if ((await initiallyOpenChatClose.count()) > 0) {
|
||||
await initiallyOpenChatClose.click()
|
||||
await expect
|
||||
.poll(() => container.evaluate((element) => element.clientWidth))
|
||||
.toBeGreaterThan(widthAfterSidebarToggle)
|
||||
}
|
||||
const widthWithoutSidebar = await container.evaluate((element) => element.clientWidth)
|
||||
const headersWithoutSidebar = await visibleHeaders(container)
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Sollinha — Assistente virtual' })
|
||||
.filter({ visible: true })
|
||||
.click()
|
||||
await expect
|
||||
.poll(() => container.evaluate((element) => element.clientWidth))
|
||||
.toBeLessThan(widthWithoutSidebar)
|
||||
await expect
|
||||
.poll(async () => (await visibleHeaders(container)).length)
|
||||
.toBeLessThan(headersWithoutSidebar.length)
|
||||
const widthWithChat = await container.evaluate((element) => element.clientWidth)
|
||||
const headersWithChat = await visibleHeaders(container)
|
||||
await page
|
||||
.getByRole('button', { name: 'Fechar', exact: true })
|
||||
.filter({ visible: true })
|
||||
.click()
|
||||
await expect
|
||||
.poll(() => container.evaluate((element) => element.clientWidth))
|
||||
.toBeGreaterThan(widthWithChat)
|
||||
await expect
|
||||
.poll(async () => (await visibleHeaders(container)).length)
|
||||
.toBeGreaterThan(headersWithChat.length)
|
||||
expect(page.viewportSize()).toEqual(viewportBefore)
|
||||
await expectNoHorizontalOverflow(container)
|
||||
})
|
||||
|
||||
test('keeps Dobradinha restricted even when an advisor has enough room', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
await loginWithAllColumns(page, campaign, 'advisor')
|
||||
const container = municipalityContainer(page)
|
||||
await setContainerWidth(container, 84 * REM_IN_PIXELS + 1)
|
||||
|
||||
await expect(container.getByRole('columnheader', { name: /Assessores/ })).toBeVisible()
|
||||
await expect(container.getByRole('columnheader', { name: 'Dobradinha' })).toHaveCount(0)
|
||||
await expectNoHorizontalOverflow(container)
|
||||
})
|
||||
|
||||
test('keeps the pinned name and 44px edit targets on an iPad viewport', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 834, height: 1194 })
|
||||
await loginWithAllColumns(page, campaign)
|
||||
const container = municipalityContainer(page)
|
||||
// Force the container past 48rem on the iPad viewport — the sidebar
|
||||
// normally leaves too little content width for the table to appear.
|
||||
await setContainerWidth(container, 48 * REM_IN_PIXELS + 1)
|
||||
const table = container.getByRole('table')
|
||||
await expect(table).toBeVisible()
|
||||
|
||||
const stickyPosition = await table
|
||||
.getByRole('columnheader', { name: /Município/ })
|
||||
.evaluate((element) => getComputedStyle(element).position)
|
||||
expect(stickyPosition).toBe('sticky')
|
||||
|
||||
for (const trigger of [
|
||||
table.getByRole('button', { name: /^Editar tendência política/ }).first(),
|
||||
table.getByRole('button', { name: /^Registrar sinal em/ }).first(),
|
||||
]) {
|
||||
const box = await trigger.boundingBox()
|
||||
expect(box?.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box?.height).toBeGreaterThanOrEqual(44)
|
||||
}
|
||||
await expectNoHorizontalOverflow(container)
|
||||
})
|
||||
})
|
||||
|
|
@ -22,7 +22,7 @@ let cookieJar = ''
|
|||
|
||||
const COLUMNS = [
|
||||
{ id: 'name', label: 'Município', mandatory: true },
|
||||
{ id: 'region', label: 'Território' },
|
||||
{ id: 'goalCoverage', label: 'Cobertura' },
|
||||
{ id: 'trend', label: 'Tendência' },
|
||||
]
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ describe('CampaignColumnPicker', () => {
|
|||
render(picker([]))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
|
||||
// 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)
|
||||
|
|
@ -84,7 +84,7 @@ describe('CampaignColumnPicker', () => {
|
|||
closeMenu()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:region~trend')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
|
@ -96,14 +96,14 @@ describe('CampaignColumnPicker', () => {
|
|||
try {
|
||||
render(picker([]))
|
||||
openMenu()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
// 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:region')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage')
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
||||
closeMenu()
|
||||
|
|
@ -114,11 +114,11 @@ describe('CampaignColumnPicker', () => {
|
|||
})
|
||||
|
||||
it('does not refresh a session that ends where it started', () => {
|
||||
render(picker(['region']))
|
||||
render(picker(['goalCoverage']))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
|
||||
closeMenu()
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
|
@ -130,10 +130,21 @@ describe('CampaignColumnPicker', () => {
|
|||
|
||||
// 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: 'Território' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
|
||||
unmount()
|
||||
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:region')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage')
|
||||
})
|
||||
|
||||
it('serializes restoring every municipality column as an explicit empty choice', () => {
|
||||
render(picker(['trend']))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restaurar todas' }))
|
||||
closeMenu()
|
||||
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:__none__')
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never lets the mandatory column be unchecked', () => {
|
||||
|
|
@ -150,7 +161,7 @@ describe('CampaignColumnPicker', () => {
|
|||
const { rerender } = render(picker([]))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' }))
|
||||
closeMenu()
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
|
||||
|
||||
|
|
@ -158,8 +169,8 @@ describe('CampaignColumnPicker', () => {
|
|||
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 `region` alone, dropping `trend`.
|
||||
rerender(picker(['region']))
|
||||
// would uncheck nothing and re-commit `goalCoverage` alone, dropping `trend`.
|
||||
rerender(picker(['goalCoverage']))
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'Tendência' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
|
|
@ -167,19 +178,19 @@ describe('CampaignColumnPicker', () => {
|
|||
|
||||
closeMenu()
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2))
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:region~trend')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend')
|
||||
})
|
||||
|
||||
it('adopts the server payload once nothing is pending', async () => {
|
||||
const { rerender } = render(picker([]))
|
||||
|
||||
rerender(picker(['region']))
|
||||
rerender(picker(['goalCoverage']))
|
||||
openMenu()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Território' }).getAttribute('aria-checked'),
|
||||
).toBe('false'),
|
||||
expect(screen.getByRole('checkbox', { name: 'Cobertura' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
),
|
||||
)
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import {
|
||||
parseCampaignHiddenColumns,
|
||||
resolveCampaignColumnVisibility,
|
||||
resolveVisibleColumns,
|
||||
serializeCampaignHiddenColumns,
|
||||
toggleHiddenColumn,
|
||||
|
|
@ -18,9 +19,13 @@ describe('parseCampaignHiddenColumns', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('shows the full table when the cookie is absent, empty or malformed', () => {
|
||||
it('keeps an absent list distinct from an explicitly empty list', () => {
|
||||
expect(parseCampaignHiddenColumns(undefined)).toEqual({})
|
||||
expect(parseCampaignHiddenColumns('')).toEqual({})
|
||||
expect(parseCampaignHiddenColumns('municipios:__none__')).toEqual({ municipios: [] })
|
||||
})
|
||||
|
||||
it('ignores malformed entries instead of guessing', () => {
|
||||
expect(parseCampaignHiddenColumns('garbage')).toEqual({})
|
||||
expect(parseCampaignHiddenColumns(':kind')).toEqual({})
|
||||
expect(parseCampaignHiddenColumns('municipios:')).toEqual({})
|
||||
|
|
@ -42,12 +47,13 @@ describe('parseCampaignHiddenColumns', () => {
|
|||
})
|
||||
|
||||
describe('serializeCampaignHiddenColumns', () => {
|
||||
it('round-trips through parse and omits lists with nothing hidden', () => {
|
||||
const value = { municipios: ['kind', 'trend'], demandas: ['requester'] }
|
||||
it('round-trips hidden columns and explicit empty lists', () => {
|
||||
const value = { municipios: ['kind', 'trend'], demandas: [] }
|
||||
const serialized = serializeCampaignHiddenColumns(value)
|
||||
|
||||
expect(parseCampaignHiddenColumns(serialized)).toEqual(value)
|
||||
expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('')
|
||||
expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('municipios:__none__')
|
||||
expect(serializeCampaignHiddenColumns({})).toBe('')
|
||||
})
|
||||
|
||||
it('never emits a character that would break the cookie value', () => {
|
||||
|
|
@ -57,6 +63,26 @@ describe('serializeCampaignHiddenColumns', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('resolveCampaignColumnVisibility', () => {
|
||||
it('applies compact defaults only when municipios is absent', () => {
|
||||
expect(resolveCampaignColumnVisibility('municipios', {})).toEqual({
|
||||
listId: 'municipios',
|
||||
hiddenColumnIds: ['goalCoverage', 'lastSignal'],
|
||||
})
|
||||
expect(resolveCampaignColumnVisibility('municipios', { municipios: [] })).toEqual({
|
||||
listId: 'municipios',
|
||||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the existing empty default for every other list', () => {
|
||||
expect(resolveCampaignColumnVisibility('liderancas', {})).toEqual({
|
||||
listId: 'liderancas',
|
||||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleHiddenColumn', () => {
|
||||
it('hides, shows and never duplicates an id', () => {
|
||||
const hidden = toggleHiddenColumn([], 'kind', false)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared
|
|||
|
||||
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
|
||||
import { formatAdvisorNamesTooltip } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
|
||||
import {
|
||||
MunicipalityList,
|
||||
municipalityListPickerColumns,
|
||||
} from '@/components/campaign/municipality/MunicipalityList'
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||||
import { TseZoneBadge } from '@/components/campaign/municipality/TseZoneBadge'
|
||||
|
|
@ -62,6 +65,34 @@ const municipalityListDefaultProps = {
|
|||
state: { page: 1 },
|
||||
}
|
||||
|
||||
const responsiveMunicipality = stub<MunicipalityListViewModel>({
|
||||
id: 1,
|
||||
name: 'São Francisco do Conde com nome extenso',
|
||||
slug: 'sao-francisco-do-conde',
|
||||
kind: 'municipio',
|
||||
city: 'São Francisco do Conde',
|
||||
region: 'Metropolitano de Salvador',
|
||||
ibgeCode: '2929206',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
territorialClass: 'sem_base',
|
||||
territorialClassFactors: [],
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
})
|
||||
|
||||
const mockAppRouter = stub<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
|
||||
push: () => {},
|
||||
replace: () => {},
|
||||
|
|
@ -279,15 +310,24 @@ describe('campaign visual foundation', () => {
|
|||
// B155: the Lideranças column chips the linked leaderships by name.
|
||||
expect(html).toContain('Lideranças')
|
||||
expect(html).toContain('Maria de Jesus')
|
||||
// B22: every one of the 11 staff columns carries a header explanation —
|
||||
// B22: every one of the 10 visible-by-default staff columns carries a header explanation —
|
||||
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
|
||||
// the mobile card's own class tooltip (B42) add triggers of their own.
|
||||
const [, tbodyHtml = ''] = html.split('<tbody')
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
|
||||
|
||||
// B41: horizontal scroll table with a pinned Município column header.
|
||||
// B158: the table is container-driven and clips instead of delegating to a
|
||||
// horizontal scroller. Município remains pinned and owns the territory subline.
|
||||
expect(html).toContain('data-container="municipality-list"')
|
||||
expect(html).toContain('@container/municipality-list')
|
||||
expect(html).toContain('@min-[48rem]/municipality-list:block')
|
||||
expect(html).toContain('@min-[48rem]/municipality-list:hidden')
|
||||
expect(html).toContain('data-slot="table-container"')
|
||||
expect(html).toContain('supports-[container-type:inline-size]:overflow-x-hidden')
|
||||
// overflow-x-auto is the fallback for browsers without container queries;
|
||||
// the test above still proves the container-query path takes precedence.
|
||||
expect(html).toContain('overflow-x-auto')
|
||||
expect(theadHtml).toContain('Município')
|
||||
expect(tbodyHtml).toContain('Seabra')
|
||||
})
|
||||
|
|
@ -338,11 +378,10 @@ describe('campaign visual foundation', () => {
|
|||
}),
|
||||
)
|
||||
|
||||
// B157: the new column's header explanation makes it 12 staff tooltips
|
||||
// (11 with B155's Lideranças column + this one).
|
||||
// B157/B158: Dobradinha adds one explanation to the 10-column staff baseline.
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(12)
|
||||
expect(theadHtml).toContain('Dobradinhas')
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||||
expect(theadHtml).toContain('Dobradinha')
|
||||
// Avatar-stack display: initials circles + the names in the trigger's
|
||||
// accessible label (the closed cell reads WHO is assigned without opening).
|
||||
expect(html).toContain('>F<') // Fulano
|
||||
|
|
@ -663,10 +702,113 @@ describe('campaign visual foundation', () => {
|
|||
expect(html).not.toContain('Faltam')
|
||||
expect(html).not.toContain('Prioritária')
|
||||
expect(html).not.toContain(`aria-label="${municipalityPriorityIndicatorLabel}"`)
|
||||
// B22: the leader view's 5 columns (name/region/kind/votos/lastUpdateAt)
|
||||
// also carry header explanations.
|
||||
// B22/B158: the leader view keeps only Município, 2022 and Atualização;
|
||||
// all three carry header explanations.
|
||||
const [theadHtml] = html.split('<tbody')
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(4)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
describe('B158 responsive municipality columns', () => {
|
||||
const renderResponsiveList = (hiddenColumnIds: string[] = []) =>
|
||||
renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [responsiveMunicipality],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
...municipalityListDefaultProps,
|
||||
isStaffView: true,
|
||||
isCampaignUnrestricted: true,
|
||||
columnVisibility: { listId: 'municipios', hiddenColumnIds },
|
||||
}),
|
||||
)
|
||||
|
||||
it('keeps the table order, territory subline, and symmetric container-query classes', () => {
|
||||
const html = renderResponsiveList()
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
const tbodyHtml = html.slice(html.indexOf('<tbody'), html.indexOf('</tbody>'))
|
||||
const labels = [
|
||||
'Município',
|
||||
'2022',
|
||||
'2026',
|
||||
'Nível',
|
||||
'Classe',
|
||||
'Assessor',
|
||||
'Tendência',
|
||||
'Liderança',
|
||||
'Dobradinha',
|
||||
'Cobertura',
|
||||
'Sinal',
|
||||
]
|
||||
|
||||
let previousIndex = -1
|
||||
for (const label of labels) {
|
||||
const labelIndex = theadHtml.indexOf(label)
|
||||
expect(labelIndex, `${label} deve aparecer depois da coluna anterior`).toBeGreaterThan(
|
||||
previousIndex,
|
||||
)
|
||||
previousIndex = labelIndex
|
||||
}
|
||||
|
||||
expect(theadHtml).not.toContain('Território')
|
||||
const nameCellHtml = tbodyHtml.split('<td')[1] ?? ''
|
||||
expect(nameCellHtml).toContain('São Francisco do Conde com nome extenso')
|
||||
expect(nameCellHtml).toContain('Metropolitano de Salvador')
|
||||
expect(nameCellHtml).toContain('href="/campanha/territorios#ti-metropolitano-de-salvador"')
|
||||
|
||||
for (const className of [
|
||||
'hidden @min-[54rem]/municipality-list:table-cell',
|
||||
'hidden @min-[66rem]/municipality-list:table-cell',
|
||||
'hidden @min-[72rem]/municipality-list:table-cell',
|
||||
'hidden @min-[78rem]/municipality-list:table-cell',
|
||||
]) {
|
||||
expect(html.split(className)).toHaveLength(3)
|
||||
}
|
||||
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:hidden')
|
||||
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:inline-flex')
|
||||
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:hidden')
|
||||
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:block')
|
||||
|
||||
const firstRowCells = tbodyHtml.split('<td').slice(1)
|
||||
expect(firstRowCells[3]?.split('</td>')[0]).toContain('>—</span>')
|
||||
expect(html).toContain('Sem nível')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ hidden: [], hasActions: false },
|
||||
{ hidden: ['trend'], hasActions: true },
|
||||
{ hidden: ['lastSignal'], hasActions: true },
|
||||
{ hidden: ['trend', 'lastSignal'], hasActions: true },
|
||||
])('mounts one trend and signal editor in the table for $hidden', ({ hidden, hasActions }) => {
|
||||
const html = renderResponsiveList(hidden)
|
||||
const tableHtml = html.slice(html.indexOf('<table'), html.indexOf('</table>'))
|
||||
const theadHtml = tableHtml.slice(tableHtml.indexOf('<thead'), tableHtml.indexOf('</thead>'))
|
||||
|
||||
expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(1)
|
||||
expect(tableHtml.match(/aria-label="Registrar sinal em/g)).toHaveLength(1)
|
||||
expect(theadHtml.includes('Ações')).toBe(hasActions)
|
||||
})
|
||||
|
||||
it('keeps removed and internal columns out of the municipality picker', () => {
|
||||
const columns = municipalityListPickerColumns({
|
||||
isStaffView: true,
|
||||
isCampaignUnrestricted: true,
|
||||
})
|
||||
|
||||
expect(columns.map((column) => column.id)).toEqual([
|
||||
'name',
|
||||
'votos',
|
||||
'expectedVotes',
|
||||
'level',
|
||||
'classe',
|
||||
'advisors',
|
||||
'trend',
|
||||
'leaderships',
|
||||
'stateDeputies',
|
||||
'goalCoverage',
|
||||
'lastSignal',
|
||||
])
|
||||
expect(columns).not.toContainEqual(expect.objectContaining({ id: 'region' }))
|
||||
expect(columns).not.toContainEqual(expect.objectContaining({ id: 'actions' }))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -695,10 +837,10 @@ describe('campaign visual foundation', () => {
|
|||
})
|
||||
|
||||
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
|
||||
// The staff `<thead>` already carries 11 header-explanation tooltips
|
||||
// The staff `<thead>` already carries 10 header-explanation tooltips
|
||||
// (B22), regardless of row data. This baseline isolates the advisors
|
||||
// CELL tooltip, the one this test actually exercises.
|
||||
const HEADER_TOOLTIP_COUNT = 11
|
||||
const HEADER_TOOLTIP_COUNT = 10
|
||||
const baseMunicipality = {
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
|
|
|
|||
Loading…
Reference in a new issue