Merge branch 'cursor/f24f6110' — B15 sortable municipality list headers
This commit is contained in:
commit
4b457de521
9 changed files with 400 additions and 51 deletions
|
|
@ -8,7 +8,7 @@ alwaysApply: false
|
|||
## Status
|
||||
|
||||
- **E17 entregue em código (2026-07-24):** tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`) — `src/utilities/territoryOverview.ts` (rollup puro, client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `src/utilities/loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true` — leitura regional completa para advisor é contexto, não gestão; exposição só agregados TI-level) + `src/components/campaign/TerritoryOverviewTable.tsx` (tabela shadcn densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Unit `territoryOverview.unit.spec.ts` (10 testes); tsc/lint/knip/unit 266/int 345/build verde; Aikido 0 findings. **Débito:** A11 ausente → share calculado localmente no rollup; A11 unifica quando entrar. Plano: [`tabela-ti-inicio.md`](../../../docs/plans/tabela-ti-inicio.md). Primeira fatia de E12 (estende com captura/LQ/cobertura de meta por TI).
|
||||
- **B15 registrado (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md).
|
||||
- **B15 entregue (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Verificação (tsc/lint/test/build/Aikido) passou. `/simplify` concluído no mesmo dia: reduziu os comparadores de ordenação a um helper genérico, unificou os dois ramos `payload.find` nativo/derivado, moveu labels/opções de sort para `municipalityUi.ts`, simplificou o ícone de chevron e removeu o cast `as unknown` do mock de roteador nos testes. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md).
|
||||
- **Fill-in registrado (2026-07-24):** seletor **Cenário** de `/campanha/municipios` → fileira de `MunicipalityFilters` (provider envolve filtros+resultados; overview sem seletor). Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
|
||||
- **E4R entregue em código + seed em produção (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always**; Salvador pulado; zero PII. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local e **produção Neon** (2026-07-24): arquivo canônico `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` (+ referência `Mapa_projecao_votos_Solla_2026.xlsx` só A×B), **189** municípios atualizados, **50** `priority=alta`, Salvador pulado, re-dry-run pós-apply **delta=0**. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
|
||||
- **Sync roadmap × sessão de campo (2026-07-24):** os achados da sessão real do CG (2026-07-23, `docs/CUSTOMER.md`) foram incorporados ao roadmap e aos planos de inteligência — âncora da mesa = **% da própria votação** (A11 entrega a lente; E9/E10/B13 expõem ao lado de LQ/captura); piso 150 mil resolve a meta inicial de E8; O6 vira **E16 dossiê do município** + gap **G11 emendas por município**; O7 confirma C12 não cortável; E13 rebaixado na fila de corte ("perna" é a restrição dominante); gatilho de D3 reformulado (deltas do ZAP não absorvidos pelo fluxo sede-digita/C12); FD2 absorvido por R6; O8 "ilhas isoladas" em A validar. **Decisão de produto: E4 reaberto como E4R** — seed único via script das planilhas de `docs/sheets/` (arquivo com espaços = canônico de estratégia; underscore = referência Salvador; v1 só `expectedVotes`/`priority`, sem PII; Salvador pulado com relatório). Novos itens: **E4R** (janela 1, não cortável), **A11**, **E17 tabela TI no Início** (pedido do candidato; primeira fatia de E12), **B14 município mais próximo** (geolocalização → atalho no Início staff; prompt 1×/sessão; client-only), **B15 ordenar lista por coluna**. Planos novos: `import-planilha-projecao.md`, `ranking-votos-municipio.md`, `tabela-ti-inicio.md`, `dossie-municipio.md`, `municipio-mais-proximo.md`, `ordenacao-colunas-lista-municipios.md`, `cenario-junto-filtros-municipios.md`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# B15 — Ordenar lista de municípios pelo header da coluna
|
||||
|
||||
Status: rascunho
|
||||
Status: entregue
|
||||
Atualizado em: 2026-07-24
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B15; superfície de coordenação)
|
||||
Impeccable: B — encaixe em `MunicipalityList` / `MunicipalityFilters` em `/campanha/municipios`; sem rota nova
|
||||
|
|
@ -126,3 +126,28 @@ Componentes:
|
|||
- `src/utilities/municipalityUi.ts`, `municipalityPageData.ts`, `campaignListUrl.ts`, `municipalityViewModels.ts`
|
||||
- AGENTS.md — naming; Campaign auth / staff vs leader; engineering-standards (pending, zero warnings)
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk; Feel the action; anti SaaS-grid
|
||||
|
||||
## Notas de implementação (2026-07-24)
|
||||
|
||||
- **Fase 1 (contrato URL):** `MunicipalityListState` ganhou `sort`/`dir`; `municipalityListParamNames` inclui as novas chaves; `parseMunicipalityListParams` e `buildMunicipalityListSearchParams` validam e omitem defaults (`sort=name`, `dir=asc`); `buildMunicipalitySortHref` toggles a direção e reseta `page` para 1. Incluída `votos` na allowlist para desbloquear A11.
|
||||
- **Fase 2 (loader):** `loadMunicipalityListPageBundle` aplica Payload `sort` para `name`, `region`, `kind`, `lastUpdateAt` e `politicalTrend.status`; para `expectedVotes`, `coverage` e `votos` carrega o filtrado completo, ordena em memória com nulls-last e tie-break por `name`, depois fatia a página. `votos` usa fallback por `name` até A11 implementar o rank real. `overview`/`scopeTotal` permanecem independentes da ordenação da lista.
|
||||
- **Fase 3 (desktop):** `MunicipalitySortableHead` em `MunicipalityList.tsx` substitui os headers estáticos; usa `CampaignTransitionAnchor` para participar do pending compartilhado, expõe `aria-sort` e ícone de direção. A coluna `Assessores` permanece não sortável (editor B9). A prop `state` foi acrescentada a `MunicipalityListProps` e passada pela página.
|
||||
- **Fase 4 (mobile):** select compacto "Ordenar" em `MunicipalityFilters.tsx`, visível apenas abaixo de `md`, espelhando as mesmas keys/direções; cards seguem a mesma ordem da URL.
|
||||
- **Fase 5 (verificação):** `tsc --noEmit`, `pnpm lint`, `pnpm test`, `pnpm build` e Aikido passaram; knip não introduziu dead code novo. Ajuste em `tests/unit/campaignComponents.unit.spec.ts` para fornecer `state` e envolver `MunicipalityList` em `AppRouterContext` mock (necessário porque o sort header usa `useRouter`).
|
||||
- **Decisões em aberto resolvidas:** nulls são sempre colocados no fim (com tie-break por nome) para `expectedVotes` e `coverage`; header "Praça" não renomeado (R6); `SortableTableHead` compartilhado não extraído (apenas 1 call site).
|
||||
|
||||
## Notas de `/simplify` (2026-07-24)
|
||||
|
||||
Após a entrega, os subagentes [Simplify code quality review](ef351fb2-d3b3-45ff-ae9a-ef9e66479e7f), [Simplify performance review](3892a1e5-0e97-4d23-85d7-0b7d17237b27) e [Simplify reuse review](cf39ade5-d339-4f1a-9e6b-ef4b6ffd85a5) revisaram o escopo. Os ajustes aplicados no mesmo dia foram:
|
||||
|
||||
- `src/utilities/municipalityPageData.ts`: os três comparadores (`sortByExpectedVotesCentral`, `sortByAdvisorCoverage`, `sortByNameFallback`) foram unificados em `sortByNullableValue` com tie-break por nome via `localeCompare('pt-BR')`; os dois ramos `payload.find` nativo/derivado foram unificados num único objeto de opções com `sort`/`limit`/`page` condicionais; `votos` passou a usar `payloadSortFieldByKey.votos = 'name'` para evitar full-scan desnecessário.
|
||||
- `src/utilities/municipalityUi.ts`: adicionados `formatMunicipalitySortOptionLabel`, `municipalityListSortOptions`, `serializeMunicipalitySortValue` e `parseMunicipalitySortValue`, centralizando as labels e a serialização `key|dir` do select mobile.
|
||||
- `src/components/campaign/MunicipalityFilters.tsx`: removeu `formatSortOptionLabel`/`sortOptions` locais e a type assertion `as [MunicipalityListSortKey, 'asc' | 'desc']`; passou a usar os helpers de `municipalityUi.ts` com parse validado.
|
||||
- `src/components/campaign/MunicipalityList.tsx`: `advisorEntries`/`advisorNames` saíram do corpo do componente para funções de nível superior (recebendo `advisorNamesById`); o ícone de chevron foi simplificado para uma única renderização de `ChevronDownIcon` com `-rotate-180` para ascendente; `MunicipalityList` voltou a implicit JSX return.
|
||||
- `tests/unit/campaignComponents.unit.spec.ts`: `mockAppRouter` passou a usar `stub<T>` em vez de `as unknown`.
|
||||
|
||||
Itens avaliados mas não aplicados (fora de escopo do `/simplify`):
|
||||
|
||||
- **Full-scan em sorts derivados:** `expectedVotes` e `coverage` ainda carregam todo o filtrado porque os campos não são diretamente sortáveis no Payload. Até 435 linhas é aceitável; se a tabela crescer ou o filtro se tornar muito permissivo, reabrir com índice/materialização.
|
||||
- **Passar `sort`/`dir` em vez de `state` para `MunicipalitySortableHead`:** manter `state` evita prop-drilling extra; se o componente for promovido/reutilizado, reavaliar.
|
||||
- **Generalizar `buildMunicipalitySortHref` para `campaignListUrl.ts`:** só existe um call site; extrair no segundo consumidor.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-24 (fill-in ícone+tooltip de prioridade na lista — [plano](plans/icone-prioridade-lista-municipios.md); B15; fill-in Cenário junto aos filtros; E4R ✓; E17 ✓; B14; janela 1: smoke, A11, B15, R6, E8)
|
||||
Atualizado em: 2026-07-24 (B15 ✓ ordenação por coluna na lista; fill-in ícone+tooltip de prioridade na lista — [plano](plans/icone-prioridade-lista-municipios.md); fill-in Cenário junto aos filtros — [plano](plans/cenario-junto-filtros-municipios.md); E4R ✓; E17 ✓; B14; janela 1: smoke, A11, R6, E8)
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -83,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- **D5** inbox da própria sessão → rascunhos (`municipalityUpdate`/demanda) com humano no loop · depende de D3 · [plano](plans/whatsapp-sugestao-atualizacoes.md)
|
||||
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · absorve os débitos de produto/UX remanescentes de FD2 ([field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md)): glossário inline (O3 — hipótese ainda sem evidência), triagem em lote, empty states de coordenador · gatilho: antes de 16/08
|
||||
- **B14** município mais próximo (geolocalização → atalho no Início staff) · pede permissão **1× por sessão** se ainda não concedida; matching client-side sobre centroides IBGE; Salvador multi-zona → lista filtrada até B8 F2 · ~1d · Janela 1–2 · sem deps duras · cortável · [plano](plans/municipio-mais-proximo.md)
|
||||
- **B15** ordenar lista de municípios pelo header da coluna · `?sort=`/`?dir=` na URL + clique no header (desktop) / select compacto (mobile); ordenação global no filtrado · ~0,5–1d · Janela 1 · sem deps duras; **A11** consome o contrato (key `votos`) · cortável · [plano](plans/ordenacao-colunas-lista-municipios.md)
|
||||
- ~~**B15** ordenar lista de municípios pelo header da coluna~~ — **entregue 2026-07-24** (`?sort=`/`?dir=` na URL + clique no header (desktop) / select compacto (mobile); ordenação global no filtrado; **A11** consome o contrato (key `votos`)) · [plano](plans/ordenacao-colunas-lista-municipios.md)
|
||||
|
||||
### Fill-ins abertos
|
||||
|
||||
|
|
@ -160,11 +160,11 @@ flowchart TD
|
|||
B8F2 -.ZE Salvador.-> B14n
|
||||
```
|
||||
|
||||
Paralelizáveis agora: **B15** (sort por coluna na lista; barato, desbloqueia UX de A11), **A11** (semana de onboarding; E4R ✓ seedou o quadro; A11 soft-dep B15), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
|
||||
Paralelizáveis agora: **A11** (semana de onboarding; E4R ✓ seedou o quadro; A11 soft-dep B15 ✓), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
|
||||
|
||||
### Sequência por janela (só pendentes)
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15** (sort por coluna) + **A11** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) entregue 2026-07-24; **A11** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ Paralelizáveis agora: **B15** (sort por coluna na lista; barato, desbloqueia UX
|
|||
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); C2 dados reais; assimetria declarado×estimado (relação de campo); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha).
|
||||
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B15** sort por coluna (lista continua em nome; A11 ainda pode introduzir `sort=votos` mínimo); **A11**/**E17** (baratos — cortar só em último caso); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); ~~**B15** sort por coluna~~ (entregue 2026-07-24); **A11**/**E17** (baratos — cortar só em último caso); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.
|
||||
|
||||
## Bloqueadores atuais
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
advisorOptions={advisorOptions}
|
||||
trendFormAction={setMunicipalityPoliticalTrendFormAction}
|
||||
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
|
||||
state={state}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -13,9 +13,14 @@ import { normalizedText } from '@/utilities/campaignListUrl'
|
|||
import {
|
||||
buildMunicipalityFiltersKey,
|
||||
buildMunicipalityListHref,
|
||||
DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
|
||||
DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
|
||||
municipalityKindLabels,
|
||||
municipalityListCoverageLabels,
|
||||
municipalityListSortOptions,
|
||||
parseMunicipalitySortValue,
|
||||
politicalTrendLabels,
|
||||
serializeMunicipalitySortValue,
|
||||
shouldUpdateMunicipalitySearchUrl,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityUi'
|
||||
|
|
@ -222,6 +227,30 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
<Field className="md:hidden">
|
||||
<FieldLabel htmlFor="municipality-sort">Ordenar</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-sort"
|
||||
value={serializeMunicipalitySortValue(
|
||||
state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
|
||||
state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
|
||||
)}
|
||||
onChange={(event) => {
|
||||
const parsed = parseMunicipalitySortValue(event.target.value)
|
||||
if (parsed) commitNavigation({ sort: parsed.key, dir: parsed.dir })
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
{municipalityListSortOptions.map(({ key, dir, label }) => (
|
||||
<NativeSelectOption
|
||||
key={serializeMunicipalitySortValue(key, dir)}
|
||||
value={serializeMunicipalitySortValue(key, dir)}
|
||||
>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
{hasActiveFilters ? (
|
||||
<div className="flex shrink-0 gap-2 md:self-end">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { CircleAlertIcon, CircleCheckIcon } from 'lucide-react'
|
||||
import { ChevronDownIcon, CircleAlertIcon, CircleCheckIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||||
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
|
||||
|
|
@ -15,11 +17,18 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
buildMunicipalitySortHref,
|
||||
DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
|
||||
DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
|
||||
formatMunicipalityGeographyLabel,
|
||||
municipalityKindLabels,
|
||||
municipalityListSortLabels,
|
||||
municipalityPriorityLabels,
|
||||
type MunicipalityListSortKey,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityUi'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
|
|
@ -43,8 +52,66 @@ export type MunicipalityListProps = {
|
|||
advisorOptions: EligibleAdvisorOption[]
|
||||
trendFormAction: MunicipalityStaffFormAction
|
||||
advisorsFormAction: MunicipalityStaffFormAction
|
||||
state: MunicipalityListState
|
||||
}
|
||||
|
||||
type MunicipalitySortableHeadProps = {
|
||||
state: MunicipalityListState
|
||||
sortKey: MunicipalityListSortKey
|
||||
children: ReactNode
|
||||
className?: string
|
||||
align?: 'left' | 'center'
|
||||
}
|
||||
|
||||
const MunicipalitySortableHead = ({
|
||||
state,
|
||||
sortKey,
|
||||
children,
|
||||
className,
|
||||
align = 'left',
|
||||
}: MunicipalitySortableHeadProps) => {
|
||||
const active = (state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY) === sortKey
|
||||
const dir = active ? state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR : undefined
|
||||
const isAscending = dir === 'asc'
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
className={cn(align === 'center' && 'text-center', className)}
|
||||
aria-sort={active ? (isAscending ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
<CampaignTransitionAnchor
|
||||
href={buildMunicipalitySortHref(state, sortKey)}
|
||||
className="inline-flex items-center gap-1"
|
||||
aria-label={`Ordenar por ${municipalityListSortLabels[sortKey]}`}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform',
|
||||
active ? 'text-foreground' : 'text-muted-foreground/50',
|
||||
active && isAscending && '-rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CampaignTransitionAnchor>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
|
||||
const advisorEntries = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||||
) =>
|
||||
municipality.advisorIDs.flatMap((id) => {
|
||||
const advisor = advisorNamesById.get(id)
|
||||
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
|
||||
})
|
||||
|
||||
const advisorNames = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||||
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
|
||||
|
||||
export const MunicipalityList = ({
|
||||
municipalities,
|
||||
advisorNamesById,
|
||||
|
|
@ -53,21 +120,12 @@ export const MunicipalityList = ({
|
|||
advisorOptions,
|
||||
trendFormAction,
|
||||
advisorsFormAction,
|
||||
}: MunicipalityListProps) => {
|
||||
const advisorEntries = (municipality: MunicipalityListViewModel) =>
|
||||
municipality.advisorIDs.flatMap((id) => {
|
||||
const advisor = advisorNamesById.get(id)
|
||||
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
|
||||
})
|
||||
|
||||
const advisorNames = (municipality: MunicipalityListViewModel): string[] =>
|
||||
advisorEntries(municipality).map((advisor) => advisor.name)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
state,
|
||||
}: MunicipalityListProps) => (
|
||||
<>
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorNames(municipality)
|
||||
const names = advisorNames(municipality, advisorNamesById)
|
||||
return (
|
||||
<article key={municipality.id} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
|
@ -138,19 +196,35 @@ export const MunicipalityList = ({
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Praça</TableHead>
|
||||
<TableHead>Território de identidade</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="name">
|
||||
Praça
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="region">
|
||||
Território de identidade
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="kind">
|
||||
Tipo
|
||||
</MunicipalitySortableHead>
|
||||
{isStaffView ? (
|
||||
<>
|
||||
<TableHead>Assessores</TableHead>
|
||||
<TableHead>Tendência</TableHead>
|
||||
<TableHead className="text-center">Votos estimados</TableHead>
|
||||
<TableHead>Última atualização</TableHead>
|
||||
<TableHead>Cobertura</TableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="trend">
|
||||
Tendência
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
|
||||
Votos estimados
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||||
Última atualização
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="coverage">
|
||||
Cobertura
|
||||
</MunicipalitySortableHead>
|
||||
</>
|
||||
) : (
|
||||
<TableHead>Última atualização</TableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||||
Última atualização
|
||||
</MunicipalitySortableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -190,7 +264,9 @@ export const MunicipalityList = ({
|
|||
formAction={advisorsFormAction}
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack advisors={advisorEntries(municipality)} />
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -242,4 +318,3 @@ export const MunicipalityList = ({
|
|||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
|
|||
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
|
||||
import {
|
||||
buildMunicipalityListWhere,
|
||||
DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
|
||||
DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
|
||||
municipalityPageSize,
|
||||
parseMunicipalityListParams,
|
||||
type MunicipalityListSearchParams,
|
||||
type MunicipalityListSortDirection,
|
||||
type MunicipalityListSortKey,
|
||||
} from '@/utilities/municipalityUi'
|
||||
import {
|
||||
municipalityListSelect,
|
||||
|
|
@ -49,6 +53,52 @@ export type MunicipalityListPageBundle = {
|
|||
overview: MunicipalityListOverviewData | null
|
||||
}
|
||||
|
||||
const payloadSortFieldByKey: Partial<Record<MunicipalityListSortKey, string>> = {
|
||||
name: 'name',
|
||||
region: 'region',
|
||||
kind: 'kind',
|
||||
lastUpdateAt: 'lastUpdateAt',
|
||||
trend: 'politicalTrend.status',
|
||||
votos: 'name',
|
||||
}
|
||||
|
||||
const municipalityNameCompare = (left: Municipality, right: Municipality): number =>
|
||||
left.name.localeCompare(right.name, 'pt-BR')
|
||||
|
||||
type MunicipalityValueAccessor = (municipality: Municipality) => number | null | undefined
|
||||
|
||||
const sortByNullableValue = (
|
||||
docs: Municipality[],
|
||||
dir: MunicipalityListSortDirection,
|
||||
getValue: MunicipalityValueAccessor,
|
||||
): Municipality[] => {
|
||||
const direction = dir === 'asc' ? 1 : -1
|
||||
return [...docs].sort((left, right) => {
|
||||
const leftValue = getValue(left) ?? null
|
||||
const rightValue = getValue(right) ?? null
|
||||
if (leftValue === null && rightValue === null) return municipalityNameCompare(left, right)
|
||||
if (leftValue === null) return 1
|
||||
if (rightValue === null) return -1
|
||||
if (leftValue === rightValue) return municipalityNameCompare(left, right)
|
||||
return (leftValue - rightValue) * direction
|
||||
})
|
||||
}
|
||||
|
||||
const applyDerivedMunicipalitySort = (
|
||||
docs: Municipality[],
|
||||
sortKey: MunicipalityListSortKey,
|
||||
dir: MunicipalityListSortDirection,
|
||||
): Municipality[] => {
|
||||
switch (sortKey) {
|
||||
case 'expectedVotes':
|
||||
return sortByNullableValue(docs, dir, (municipality) => municipality.expectedVotes?.central)
|
||||
case 'coverage':
|
||||
return sortByNullableValue(docs, dir, (municipality) => municipality.advisors?.length)
|
||||
default:
|
||||
return docs
|
||||
}
|
||||
}
|
||||
|
||||
export const loadMunicipalityListPageBundle = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
|
|
@ -57,6 +107,8 @@ export const loadMunicipalityListPageBundle = async (
|
|||
const state = parseMunicipalityListParams(searchParams)
|
||||
const where = buildMunicipalityListWhere(state)
|
||||
const isStaff = isCampaignStaff(user)
|
||||
const sortKey = state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
|
||||
const sortDir = state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR
|
||||
|
||||
if (isCampaignLeader(user)) {
|
||||
return {
|
||||
|
|
@ -68,18 +120,30 @@ export const loadMunicipalityListPageBundle = async (
|
|||
}
|
||||
}
|
||||
|
||||
const [paginatedResult, scopeCount, staffScope] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'municipality',
|
||||
depth: 0,
|
||||
limit: municipalityPageSize,
|
||||
page: state.page,
|
||||
sort: 'name',
|
||||
where,
|
||||
select: municipalityListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
const nativeSortField = payloadSortFieldByKey[sortKey]
|
||||
const isNative = nativeSortField !== undefined
|
||||
|
||||
const listQuery = payload.find({
|
||||
collection: 'municipality',
|
||||
depth: 0,
|
||||
where,
|
||||
select: municipalityListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
...(isNative
|
||||
? {
|
||||
limit: municipalityPageSize,
|
||||
page: state.page,
|
||||
sort: `${sortDir === 'desc' ? '-' : ''}${nativeSortField}`,
|
||||
}
|
||||
: {
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
}),
|
||||
})
|
||||
|
||||
const [listResult, scopeCount, staffScope] = await Promise.all([
|
||||
listQuery,
|
||||
payload.count({
|
||||
collection: 'municipality',
|
||||
where: {},
|
||||
|
|
@ -107,15 +171,32 @@ export const loadMunicipalityListPageBundle = async (
|
|||
}
|
||||
}
|
||||
|
||||
let pageDocs: Municipality[]
|
||||
let totalDocs: number
|
||||
let totalPages: number
|
||||
|
||||
if (isNative) {
|
||||
// Payload select narrows the inferred type; the selected fields cover the view model.
|
||||
pageDocs = listResult.docs as Municipality[]
|
||||
totalDocs = listResult.totalDocs
|
||||
totalPages = listResult.totalPages
|
||||
} else {
|
||||
const allDocs = applyDerivedMunicipalitySort(listResult.docs as Municipality[], sortKey, sortDir)
|
||||
totalDocs = allDocs.length
|
||||
totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize))
|
||||
const start = (state.page - 1) * municipalityPageSize
|
||||
pageDocs = allDocs.slice(start, start + municipalityPageSize)
|
||||
}
|
||||
|
||||
return {
|
||||
municipalities: paginatedResult.docs.map((municipality) =>
|
||||
municipalities: pageDocs.map((municipality) =>
|
||||
toMunicipalityListViewModel(
|
||||
municipality as Municipality,
|
||||
municipality,
|
||||
isStaff ? pledgeAggregates.get(municipality.id) : undefined,
|
||||
),
|
||||
),
|
||||
totalDocs: paginatedResult.totalDocs,
|
||||
totalPages: paginatedResult.totalPages,
|
||||
totalDocs,
|
||||
totalPages,
|
||||
scopeTotal: scopeCount.totalDocs,
|
||||
overview,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,32 @@ export const politicalTrendBadgeVariant = {
|
|||
desfavoravel: 'destructive',
|
||||
} as const
|
||||
|
||||
export type MunicipalityListSortKey =
|
||||
| 'name'
|
||||
| 'region'
|
||||
| 'kind'
|
||||
| 'trend'
|
||||
| 'expectedVotes'
|
||||
| 'lastUpdateAt'
|
||||
| 'coverage'
|
||||
| 'votos'
|
||||
|
||||
export type MunicipalityListSortDirection = 'asc' | 'desc'
|
||||
|
||||
export const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'name'
|
||||
export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection = 'asc'
|
||||
|
||||
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
|
||||
name: 'Praça',
|
||||
region: 'Território de identidade',
|
||||
kind: 'Tipo',
|
||||
trend: 'Tendência',
|
||||
expectedVotes: 'Votos estimados',
|
||||
lastUpdateAt: 'Última atualização',
|
||||
coverage: 'Cobertura',
|
||||
votos: 'Votos',
|
||||
}
|
||||
|
||||
export type MunicipalityListState = {
|
||||
page: number
|
||||
q?: string
|
||||
|
|
@ -48,6 +74,8 @@ export type MunicipalityListState = {
|
|||
trend?: PoliticalTrendStatus
|
||||
/** Candidate number for the map comparison mode (does not filter the list). */
|
||||
compare?: number
|
||||
sort?: MunicipalityListSortKey
|
||||
dir?: MunicipalityListSortDirection
|
||||
}
|
||||
|
||||
export type MunicipalityListSearchParams = CampaignListRawSearchParams
|
||||
|
|
@ -60,11 +88,26 @@ export const municipalityListParamNames = [
|
|||
'priority',
|
||||
'trend',
|
||||
'compare',
|
||||
'sort',
|
||||
'dir',
|
||||
'page',
|
||||
] as const
|
||||
|
||||
const municipalityListParamNameSet = new Set<string>(municipalityListParamNames)
|
||||
|
||||
const municipalityListSortKeySet = new Set<MunicipalityListSortKey>([
|
||||
'name',
|
||||
'region',
|
||||
'kind',
|
||||
'trend',
|
||||
'expectedVotes',
|
||||
'lastUpdateAt',
|
||||
'coverage',
|
||||
'votos',
|
||||
])
|
||||
|
||||
const municipalityListSortDirSet = new Set<MunicipalityListSortDirection>(['asc', 'desc'])
|
||||
|
||||
const canonicalTerritoryBySearchValue = new Map(
|
||||
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
|
||||
)
|
||||
|
|
@ -81,6 +124,10 @@ export const parseMunicipalityListParams = (params: MunicipalityListSearchParams
|
|||
const rawPriority = firstValue(params.priority)
|
||||
const rawTrend = firstValue(params.trend)
|
||||
const rawCompare = strictDecimalInteger(firstValue(params.compare))
|
||||
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
|
||||
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
|
||||
const rawDir = firstValue(params.dir) as MunicipalityListSortDirection | undefined
|
||||
const dir = rawDir && municipalityListSortDirSet.has(rawDir) ? rawDir : undefined
|
||||
|
||||
return {
|
||||
page: rawPage ?? 1,
|
||||
|
|
@ -95,6 +142,8 @@ export const parseMunicipalityListParams = (params: MunicipalityListSearchParams
|
|||
? { trend: rawTrend }
|
||||
: {}),
|
||||
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
|
||||
...(sort ? { sort } : {}),
|
||||
...(dir ? { dir } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +186,8 @@ export const buildMunicipalityListSearchParams = (
|
|||
priority: state.priority,
|
||||
trend: state.trend,
|
||||
compare: state.compare === undefined ? undefined : String(state.compare),
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
})
|
||||
const params = new URLSearchParams()
|
||||
|
||||
|
|
@ -147,6 +198,12 @@ export const buildMunicipalityListSearchParams = (
|
|||
if (canonicalState.priority) params.set('priority', canonicalState.priority)
|
||||
if (canonicalState.trend) params.set('trend', canonicalState.trend)
|
||||
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
|
||||
if (canonicalState.sort && canonicalState.sort !== DEFAULT_MUNICIPALITY_LIST_SORT_KEY) {
|
||||
params.set('sort', canonicalState.sort)
|
||||
}
|
||||
if (canonicalState.dir && canonicalState.dir !== DEFAULT_MUNICIPALITY_LIST_SORT_DIR) {
|
||||
params.set('dir', canonicalState.dir)
|
||||
}
|
||||
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
|
||||
|
||||
return params
|
||||
|
|
@ -158,6 +215,71 @@ export const buildMunicipalityFiltersKey = (state: MunicipalityListState): strin
|
|||
export const buildMunicipalityListHref = (state: MunicipalityListState, page: number): string =>
|
||||
buildListHref(state, buildMunicipalityListSearchParams, '/campanha/municipios', page)
|
||||
|
||||
const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
|
||||
'expectedVotes',
|
||||
'lastUpdateAt',
|
||||
'votos',
|
||||
]
|
||||
|
||||
export const buildMunicipalitySortHref = (
|
||||
state: MunicipalityListState,
|
||||
nextKey: MunicipalityListSortKey,
|
||||
): string => {
|
||||
const currentSort = state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
|
||||
const currentDir = state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR
|
||||
|
||||
let dir: MunicipalityListSortDirection
|
||||
if (nextKey === currentSort) {
|
||||
dir = currentDir === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
dir = sortKeysWithDescDefault.includes(nextKey) ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
const updatedState: MunicipalityListState = {
|
||||
...state,
|
||||
sort: nextKey,
|
||||
dir,
|
||||
page: 1,
|
||||
}
|
||||
|
||||
return buildMunicipalityListHref(updatedState, 1)
|
||||
}
|
||||
|
||||
export const formatMunicipalitySortOptionLabel = (
|
||||
key: MunicipalityListSortKey,
|
||||
dir: MunicipalityListSortDirection,
|
||||
): string => {
|
||||
const base = municipalityListSortLabels[key]
|
||||
if (key === 'expectedVotes' || key === 'votos') {
|
||||
return dir === 'asc' ? `${base} (menor → maior)` : `${base} (maior → menor)`
|
||||
}
|
||||
if (key === 'lastUpdateAt') {
|
||||
return dir === 'asc' ? `${base} (mais antiga)` : `${base} (mais recente)`
|
||||
}
|
||||
return dir === 'asc' ? `${base} (A–Z)` : `${base} (Z–A)`
|
||||
}
|
||||
|
||||
export const municipalityListSortOptions = (
|
||||
Object.keys(municipalityListSortLabels) as MunicipalityListSortKey[]
|
||||
).flatMap((key) => [
|
||||
{ key, dir: 'asc' as const, label: formatMunicipalitySortOptionLabel(key, 'asc') },
|
||||
{ key, dir: 'desc' as const, label: formatMunicipalitySortOptionLabel(key, 'desc') },
|
||||
])
|
||||
|
||||
export const serializeMunicipalitySortValue = (
|
||||
key: MunicipalityListSortKey,
|
||||
dir: MunicipalityListSortDirection,
|
||||
): string => `${key}|${dir}`
|
||||
|
||||
export const parseMunicipalitySortValue = (
|
||||
value: string,
|
||||
): { key: MunicipalityListSortKey; dir: MunicipalityListSortDirection } | null => {
|
||||
const [rawKey, rawDir] = value.split('|')
|
||||
if (!municipalityListSortKeySet.has(rawKey as MunicipalityListSortKey)) return null
|
||||
if (!municipalityListSortDirSet.has(rawDir as MunicipalityListSortDirection)) return null
|
||||
return { key: rawKey as MunicipalityListSortKey, dir: rawDir as MunicipalityListSortDirection }
|
||||
}
|
||||
|
||||
export const shouldUpdateMunicipalitySearchUrl = (
|
||||
input: string,
|
||||
currentQ: string | undefined,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { createElement } from 'react'
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
|
||||
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { MunicipalityList } from '@/components/campaign/MunicipalityList'
|
||||
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
|
||||
|
|
@ -12,6 +14,7 @@ import { Progress } from '@/components/ui/Progress'
|
|||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { stub } from '../helpers/stub'
|
||||
import type { MunicipalityAdvisorSummary, MunicipalityListViewModel } from '@/utilities/municipalityViewModels'
|
||||
import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
|
||||
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
|
||||
|
|
@ -26,8 +29,21 @@ const municipalityListDefaultProps = {
|
|||
advisorOptions: [],
|
||||
trendFormAction: noopListFormAction,
|
||||
advisorsFormAction: noopListFormAction,
|
||||
state: { page: 1 },
|
||||
}
|
||||
|
||||
const mockAppRouter = stub<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
|
||||
push: () => {},
|
||||
replace: () => {},
|
||||
refresh: () => {},
|
||||
forward: () => {},
|
||||
back: () => {},
|
||||
prefetch: () => Promise.resolve(),
|
||||
})
|
||||
|
||||
const renderWithAppRouter = (element: React.ReactElement) =>
|
||||
renderToStaticMarkup(createElement(AppRouterContext.Provider, { value: mockAppRouter }, element))
|
||||
|
||||
describe('campaign visual foundation', () => {
|
||||
it('exposes the current progress value to assistive technology', () => {
|
||||
const html = renderToStaticMarkup(createElement(Progress, { value: 42 }))
|
||||
|
|
@ -162,7 +178,7 @@ describe('campaign visual foundation', () => {
|
|||
},
|
||||
]
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
const html = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities,
|
||||
advisorNamesById: new Map([[advisor.id, advisor]]),
|
||||
|
|
@ -183,7 +199,7 @@ describe('campaign visual foundation', () => {
|
|||
})
|
||||
|
||||
it('hides leadership coverage subline when pledges only have declared votes', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
const html = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [
|
||||
{
|
||||
|
|
@ -221,7 +237,7 @@ describe('campaign visual foundation', () => {
|
|||
})
|
||||
|
||||
it('hides staff-only pledge and coverage columns from the leader view', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
const html = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue