Merge branch 'cursor/2f22c6f6'
This commit is contained in:
commit
f63cd2cd50
18 changed files with 1156 additions and 102 deletions
|
|
@ -17,6 +17,7 @@ alwaysApply: false
|
|||
**UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`.
|
||||
|
||||
Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba".
|
||||
- **B33 entregue em código (2026-07-26):** ordenação e filtro no header de `/campanha/dobradinhas` — `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` (contrato de URL/filtro próprio, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido") espelhando `municipalityListUrl.ts` (paginação/`resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) — não `leadershipListUrl.ts`, que o plano mandava espelhar mas **não existe** (B29 não entregou). `StateDeputySortableHead`/`StateDeputyHeaderFilter` consomem o chrome já compartilhado pelo **B21 ✓** sem extrair de novo (a corrida de ordem de chegada com B29 está encerrada); `loadStateDeputyPartyFacet` segue o contrato de faceta de `loadMunicipalityListFilterFacets` (respeita a busca, ignora o próprio filtro do popover). Ordem dos nulos de `party` **pinada por int test** em vez de assumida — Postgres/Payload ordena nulls por último em `asc` e primeiro em `desc`; "Sem partido" continua sendo o caminho direto via `party: { exists: false }`. Paridade mobile (`StateDeputyFilters`, espelho de `TerritoryFilters`) entrou por decisão desta sessão, fora do appetite original. Redirect canônico em duas fases (`resolveStateDeputyListUrl` → `totalPages` → resolve de novo) no molde de `municipios/page.tsx`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. **Correção pós-entrega no mesmo dia (design review independente):** mobile não tinha a opção "Sem partido" que o popover desktop já injetava (`hasNoParty` não chegava a `StateDeputyFilters`) e a tabela não passava `caption` ao `CampaignTable`, ao contrário dos dois precedentes citados no plano — ambos corrigidos no mesmo molde; copy do empty state também virou plural. **`/simplify` no mesmo dia:** a correção acima tinha copiado a montagem da linha "Sem partido" em vez de compartilhá-la — extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido por `StateDeputyFilters` e `StateDeputyHeaderFilter`; `StateDeputySortableHead` perdeu o discriminante `filterParam: 'party'` (só há uma coluna filtrável, `filterOptions` já basta); `parsePartiesParam` parou de reimplementar o dedupe de `allParamValues`; um `if` sempre-verdadeiro saiu de `buildStateDeputyListWhere`; `loadStateDeputyListPageData` ganhou `select` na query principal. Gate reverificado verde. **`/simplify` pós-`/rebase-on-main` (2ª rodada, mesmo dia):** rodou de novo sobre o commit squashado — removeu tipo+cast morto `StateDeputyPartyRow` (`stateDeputyData.ts`, `select` só afeta runtime, não a checagem estática), `stateDeputyColumns` (`page.tsx`) passou a importar `StateDeputyFilterOption` em vez de redeclarar a forma, `parsePartiesParam` foi inlineado (helper de uma linha usado uma vez). Achado maior: `firstPartyNamesLabel` era a 3ª cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites"; extraído `truncatedNamesLabel` em `campaignListUrl.ts`, os três domínios migrados. Recomendação de hook compartilhado para o scaffold de busca debounced (`StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo ~50 linhas) registrada mas não perseguida — tocaria os três domínios, fora do escopo deste item; capturada no fechamento como **B33+** (fill-in de engenharia, `useCampaignListFilterNavigation`, ~0,5d). Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. Agregado SQL para o facet de partido também adiado com gatilho (só compensa em escala). Plano: [`ordenacao-filtros-lista-dobradinhas.md`](../../../docs/plans/ordenacao-filtros-lista-dobradinhas.md) · fill-in: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md).
|
||||
- **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover <nome>"`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
|
||||
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
|
||||
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).
|
||||
|
|
|
|||
85
docs/plans/escala-dry-pos-b33.md
Normal file
85
docs/plans/escala-dry-pos-b33.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Escala e DRY pós-B33 (ordenação e filtros de dobradinhas)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-26
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B33+**, fill-in de engenharia)
|
||||
Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de URL nem markup visível)
|
||||
Appetite: ~0,5 dia eng; fase única, sem migration, sem collection, sem `Consent`
|
||||
Responsável: —
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Não (N/A) — o lote é custo de manutenção de um hook de navegação já entregue três vezes; nenhuma métrica, série ou escala nova.
|
||||
- **Anti-goals de dado:** N/A.
|
||||
|
||||
## Contexto
|
||||
|
||||
**B33** ([ordenacao-filtros-lista-dobradinhas.md](ordenacao-filtros-lista-dobradinhas.md), entregue 2026-07-26) adicionou paridade mobile (`StateDeputyFilters`) espelhando `TerritoryFilters` (B21 ✓) e `MunicipalityFilters` (B16 ✓). A 2ª passagem `/simplify` da entrega (revisor reuse, 2026-07-26) confirmou que as três já são o **3º call site** do mesmo scaffold de navegação: `SEARCH_DEBOUNCE_MS = 1000`, `useState` de busca, fallback `useCampaignListPending() ?? useTransition()` local, `debounceRef` com cleanup no `useEffect`, `clearDebounce`, `navigateTo` (compara o href canônico com o atual e só navega se mudou) e `scheduleSearch` (debounce que reusa `navigateTo`) — ~35–50 linhas idênticas ou quase idênticas em cada um dos três arquivos:
|
||||
|
||||
- `src/components/campaign/stateDeputy/StateDeputyFilters.tsx:31–90`
|
||||
- `src/components/campaign/municipality/TerritoryFilters.tsx:31–84`
|
||||
- `src/components/campaign/municipality/MunicipalityFilters.tsx:35–96`
|
||||
|
||||
A diferença entre os três está só em **como** o `next: State` vira `State` canônico e `href` — `StateDeputyFilters`/`TerritoryFilters` chamam `parseXListParams(xListStateToRawParams(...))` seguido de `buildXListHref`; `MunicipalityFilters` compara por `buildMunicipalityFiltersKey` em vez de recomputar o href duas vezes. O corpo do `<form>` (busca + resumo de filtros + botão "Limpar" + bloco mobile com `Field`/`NativeSelect` de ordenação) também se repete, mas os filtros do meio do bloco mobile (Partido/Território+Assessoria/4 multi-filtros) divergem por domínio — não faz parte deste lote.
|
||||
|
||||
O E10 já havia identificado esse padrão previamente (ver "Adiado com gatilho" de [escala-dry-pos-e10.md](escala-dry-pos-e10.md)) para os multi-filtros do **bloco mobile interno**; este lote é sobre o **scaffold de navegação/debounce** ao redor deles, que é outro pedaço do mesmo arquivo.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Um único hook (`useCampaignListFilterNavigation`) concentra `search`/`setSearch`, `isPending`, `navigateTo` e `scheduleSearch`; os três componentes de filtro passam a chamá-lo em vez de reimplementar `debounceRef`/`useEffect`/`clearDebounce`.
|
||||
- Comportamento visível idêntico: mesmo debounce de 1000ms, mesmo dimming via `useCampaignListPending`, mesmas URLs geradas — sem migration, sem mudança de contrato de URL.
|
||||
- Guardrail: o hook não decide **o que** é canônico (isso continua em `campaignListUrl.ts` + módulo por domínio); ele só orquestra o ciclo debounce → canonicalizar → comparar → `router.replace` dentro de uma transition.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **O hook recebe callbacks (`toCanonical`, `toHref`), não os módulos de URL por domínio.** Assim ele não importa `stateDeputyListUrl`/`territoryListUrl`/`municipalityListUrl` e continua em `shared/` sem inverter a direção de dependência (`components/campaign/<domínio>` → hook genérico, nunca o contrário). Fonte: `/simplify` B33 rodada 2 (2026-07-26), reuse review. **Rejeitado:** o hook aceitar o `State` genérico e um único `resolveListUrl` importado de `campaignListUrl.ts` — os três domínios já têm assinaturas de canonicalização levemente diferentes (`MunicipalityFilters` compara por `buildMunicipalityFiltersKey`, os outros dois recomputam href duas vezes), então forçar uma assinatura comum agora exigiria mudar `buildXListHref`/`parseXListParams` dos três módulos só para caber no hook — inverte o custo (a duplicação é mais barata que a unificação de assinatura).
|
||||
- **Só o scaffold de navegação sai; o `<form>`/JSX permanece por componente.** O corpo visual diverge o bastante (Partido vs Território+Assessoria vs 4 multi-filtros) que um componente de shell genérico viraria um `children`-render-prop só para economizar ~15 linhas de JSX repetido (input de busca + resumo + botão Limpar), o que é o tipo de abstração prematura que o `engineering-standards.mdc` pede para evitar. **Rejeitado:** `CampaignListFilterBar` com slot para os filtros do meio — analisado e descartado por esticar a interface para 3 usos ainda divergentes.
|
||||
- **i18n e naming** seguem o AGENTS.md: identificador em inglês (`useCampaignListFilterNavigation`), sem strings visíveis novas.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O hook expõe `scheduleSearch` só para o campo de busca, ou também um `commitNow` para os outros controles (select/popover) chamarem `navigateTo` direto?** **Opções:** (a) expor `navigateTo` cru + `scheduleSearch` (debounce) como duas funções separadas do hook; (b) só `scheduleSearch`, com os outros controles chamando `navigateTo` importado à parte. **Recomendação:** (a) — é exatamente o que os três componentes já fazem hoje (search usa `scheduleSearch`, sort/filtros usam `navigateTo` direto), então o hook só precisa expor os dois nomes que já existem, sem inventar terceiro.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Hook["useCampaignListFilterNavigation<br/>(shared/)"] --> SD["StateDeputyFilters"]
|
||||
Hook --> TF["TerritoryFilters"]
|
||||
Hook --> MF["MunicipalityFilters"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`useCampaignListFilterNavigation`** (novo, `src/components/campaign/shared/useCampaignListFilterNavigation.ts`): recebe `{ initialQuery, toCanonicalHref(next), isDebounced }`, devolve `{ search, setSearch, isPending, navigateTo(next), scheduleSearch(value, buildNext) }`. Internamente reusa `useCampaignListPending()` com fallback a `useTransition()` local (mesmo padrão dos três arquivos hoje) e o `debounceRef`/cleanup.
|
||||
- **`StateDeputyFilters.tsx`** / **`TerritoryFilters.tsx`** / **`MunicipalityFilters.tsx`**: substituem os blocos duplicados (linhas citadas acima) pela chamada ao hook, passando o `toCanonicalHref` específico do domínio (que já existe: `parseXListParams` + `buildXListHref`, ou `buildMunicipalityFiltersKey`). JSX inalterado.
|
||||
- **Migration**: sem migration, sem collection, sem server action.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma de outro plano aberto. Reusa `useCampaignListPending` (`shared/CampaignListPending.tsx`) já compartilhado pelos três.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- **Unificar o `<form>`/JSX dos três filtros** — analisado e rejeitado acima; cada domínio mantém seu próprio corpo mobile.
|
||||
- **Multi-filtros do bloco mobile saindo de `*ListFilters.ts` definitions** — já é o F4 de [escala-dry-pos-e10.md](escala-dry-pos-e10.md); este lote não duplica esse escopo.
|
||||
- **Extrair o par head/filtro do header desktop (`CampaignSortableHead`/`CampaignHeaderFilterPopover`)** — já compartilhado desde B21 ✓; fora deste lote, que é só o scaffold de busca/debounce.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que estou extraindo, generalizo `State` com um único módulo de URL parametrizado."** Se alguém "só completar": os três contratos de URL (`stateDeputyListUrl.ts`/`territoryListUrl.ts`/`municipalityListUrl.ts`) colapsam num DSL genérico, exatamente o anti-padrão que "Decisões travadas" de B33/B21/B29 já rejeitaram por acoplar três contratos independentes num só ponto de mudança. **Mitigação neste item:** o hook recebe callbacks, nunca importa os módulos de URL por domínio.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
Nenhum neste item.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Fill-ins abertos — **B33+**)
|
||||
- [ordenacao-filtros-lista-dobradinhas.md](ordenacao-filtros-lista-dobradinhas.md) — o pai do lote (B33 ✓)
|
||||
- [escala-dry-pos-e10.md](escala-dry-pos-e10.md) — F4, o débito irmão do bloco mobile interno (não sobreposto por este lote)
|
||||
- `src/components/campaign/stateDeputy/StateDeputyFilters.tsx` (`:31`–`:90`)
|
||||
- `src/components/campaign/municipality/TerritoryFilters.tsx` (`:31`–`:84`)
|
||||
- `src/components/campaign/municipality/MunicipalityFilters.tsx` (`:35`–`:96`)
|
||||
- `src/components/campaign/shared/CampaignListPending.tsx` — `useCampaignListPending`, reusado pelo hook novo
|
||||
- AGENTS.md — módulo de dependência `lib/`→`utilities/`→`components/`; abstrações precisam de 3+ call sites (aqui há exatamente 3, o piso do próprio AGENTS.md)
|
||||
|
|
@ -79,7 +79,7 @@ Componentes:
|
|||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **`TERRITORIAL_CLASSES` sem consumidor e as três derivações `Object.keys(...) as ...`.** Mover a tupla + tipos para módulo de contrato client-safe removeria o cast, mas `trend` usa o **mesmo** idioma no mesmo arquivo — corrigir só o novo deixa o arquivo menos consistente. Revisitar quando: 3º enum multi entrar na lista (o **B33** traz Partido em `/campanha/dobradinhas`), tratando trend+class+partido de uma vez.
|
||||
- **`TERRITORIAL_CLASSES` sem consumidor e as três derivações `Object.keys(...) as ...`.** Mover a tupla + tipos para módulo de contrato client-safe removeria o cast, mas `trend` usa o **mesmo** idioma no mesmo arquivo — corrigir só o novo deixa o arquivo menos consistente. Revisitar quando: 3º enum **fechado** multi entrar na lista, tratando trend+class+esse novo de uma vez — **o B33 (entregue 2026-07-26) não disparou este gatilho**: Partido em `/campanha/dobradinhas` é texto livre (sentinela `NO_PARTY_FILTER_VALUE` para "sem partido"), não um enum fechado como `trend`/`class`, então `parseExhaustiveEnumParam` não se aplica a ele (confirmado no `/simplify` do B33).
|
||||
- **`TerritorialClassRow` reimplementa o trigger do `GoalAccountMetric`.** Só o estilo do botão é duplicado — o comportamento de toque/dismiss vive no `CampaignHoverTooltip` compartilhado, então o risco de regressão assimétrica é menor do que parece. Revisitar sob o gatilho **já registrado** em [conta-da-cadeira.md](conta-da-cadeira.md): quando E9–E14 adicionarem uma 5ª métrica a essa `dl`/grid, decidir layout/idioma antes de replicar o componente de novo.
|
||||
- **Três caches de módulo no classificador viram um só.** Legibilidade, não custo — o sort redundante já morreu no simplify. Revisitar quando **E15** recalibrar as âncoras (é quando alguém vai reler as três lifetimes de uma vez).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# B33 — Ordenação e filtro no header da lista de dobradinhas
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Status: entregue em código
|
||||
Atualizado em: 2026-07-26
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B33)
|
||||
Impeccable: B — encaixe em tela existente (`/campanha/dobradinhas`), sem rota nova; consome o header rico compartilhado entregue pelo **B21 ✓**
|
||||
Appetite: ~0,5–0,75 dia eng; `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` já existem
|
||||
|
|
@ -9,6 +9,24 @@ Responsável: —
|
|||
|
||||
**Atualização B21 (2026-07-25):** a corrida de ordem de chegada foi encerrada. B33 não extrai nem duplica chrome; implementa somente `StateDeputySortableHead`/`StateDeputyHeaderFilter` e seus módulos de URL/filtros.
|
||||
|
||||
## Revisão na entrega (2026-07-26)
|
||||
|
||||
Auditoria do plano contra o repositório antes de codar achou cinco divergências, todas corrigidas na implementação (não no plano, que fica congelado como registro da decisão original):
|
||||
|
||||
1. **`leadershipListUrl.ts` não existe** (B29 não entregou) — espelhado `municipalityListUrl.ts` (paginação, clamp via `resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) em vez de um módulo inexistente.
|
||||
2. **Ordem dos nulos em `party` não é "nulls por último em ambas as direções"** — pinado por int test o comportamento real do adapter Postgres/Payload: ASC ordena nulls por último, DESC ordena nulls primeiro. O caminho direto para achar as fichas sem partido continua sendo o filtro "Sem partido" (`party: { exists: false }`), não a posição na ordenação.
|
||||
3. **Paridade mobile entrou** (decisão desta sessão, +~0,25d sobre o appetite) — `StateDeputyFilters.tsx` espelha `TerritoryFilters.tsx`: busca com debounce, resumo de filtros + "Limpar", bloco `md:hidden` com `CampaignMobileMultiFilterField` (Partido) e `NativeSelect` "Ordenar".
|
||||
4. **`parseStateDeputyListParams` saiu do `describe.each` compartilhado** de `tests/unit/campaignEntityListParsers.unit.spec.ts` (cresceu um contrato de sort/filtro próprio) e ganhou spec dedicada em `tests/unit/stateDeputyListUrl.unit.spec.ts`.
|
||||
5. **Redirect canônico em duas fases** (`resolveStateDeputyListUrl(params)` → carrega `totalPages` → `resolveStateDeputyListUrl(params, totalPages)`) implementado no molde de `municipios/page.tsx`, evitando `?page=9` fora do range.
|
||||
|
||||
**Correção pós-entrega (2026-07-26, achada por design review independente):** dois defeitos reais — `StateDeputyFilters` (mobile) montava as opções de "Partido" só a partir da faceta, sem a sentinela "Sem partido" que o popover desktop já injetava (`hasNoParty`), então um usuário só-mobile não conseguia filtrar/desmarcar esse valor mesmo vendo-o no resumo de filtros ativos; e `CampaignTable` não recebia `caption` nesta lista, ao contrário dos dois precedentes citados no plano (`MunicipalityList`/`TerritoryList` sempre populam esse prop) — um usuário navegando por landmark de tabela (leitor de tela) não tinha nome/descrição acessível. Ambos corrigidos no mesmo padrão dos precedentes (`hasNoParty` propagado a `StateDeputyFilters`, `caption={sortSummary + descrição}` na tabela); copy do empty state também corrigida de singular ("a dobradinha") para plural ("as dobradinhas"). Gate verde de novo (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados nos 2 arquivos tocados.
|
||||
|
||||
**`/simplify` (2026-07-26):** três agentes de revisão paralelos (qualidade, performance, reuso) apontaram a mesma duplicação de fundo — a linha sentinela "Sem partido" era montada separadamente em `StateDeputyFilters` e `StateDeputyHeaderFilter` (a correção pós-entrega acima copiou a lógica em vez de compartilhá-la). Extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido pelos dois; `noPartyFilterLabel` deixou de ser exportado (só usado internamente agora). Outras três limpezas pontuais: `StateDeputySortableHead` derrubou o discriminante `filterParam: 'party'` — só há uma coluna filtrável, então a presença de `filterOptions` já basta (ao contrário de Territory, que tem duas); `parsePartiesParam` parou de reimplementar dedupe que `allParamValues` já faz; `buildStateDeputyListWhere` perdeu um `if (partyFilters.length)` sempre-verdadeiro (todo `parties` não vazio produz ao menos um filtro nomeado ou a sentinela). Performance: `loadStateDeputyListPageData` ganhou `select: { name, slug, party }` na query principal (a lista já projetava a faceta assim; a query da página não). Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados nos 7 arquivos tocados. Recomendações descartadas por serem debate estilístico ou fora de escopo: inlinear `defaultStateDeputyListSortDir`/`firstPartyNamesLabel` (mantidos por paridade com `territoryListUrl`/`territoryListFilters`); `Set` em vez de `.includes()` no loop de contagens (pré-existente, não tocado por este item); agregado SQL para o facet de partido (só compensa em escala, travado como Adiado com gatilho).
|
||||
|
||||
**`/simplify` pós-rebase (2026-07-26, 2ª rodada):** rodada nova pedida após `/rebase-on-main`, sobre o commit já squashado. Três achados de qualidade corrigidos: `StateDeputyPartyRow` (`Pick<StateDeputy, 'party'>`) era um tipo + cast morto em `stateDeputyData.ts` — `result.docs` já vem tipado `StateDeputy[]`, `select` só afeta o runtime, não a checagem estática; removido. `stateDeputyColumns` (`page.tsx`) redeclarava `Array<{ value: string; label: string }>` em vez de importar `StateDeputyFilterOption`, já exportado por `stateDeputyListFilters.ts`. `parsePartiesParam` era um helper de uma linha usado uma única vez — inlineado no call site. Um achado de reuso maior: `firstPartyNamesLabel` (aqui) era a **terceira** cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites" do `engineering-standards.mdc`. Extraído `truncatedNamesLabel` em `campaignListUrl.ts` (o módulo-base que os três arquivos de filtro já importam) e os três call sites migrados; nenhuma das duas listas irmãs precisou de mudança de comportamento. Performance: zero achados (dataset de dezenas de linhas, tudo já em `Promise.all`/`select` estreito da rodada anterior). Recomendação descartada por exigir refatoração muito maior que este item: o reuse reviewer também achou que `StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo scaffold de busca debounced + navegação (~50 linhas cada) — candidato a um hook compartilhado (`useCampaignListFilterNavigation`), mas tocaria os três domínios fora do escopo deste item; registrado aqui para um fill-in futuro, não perseguido agora. Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados nos 7 arquivos tocados nesta rodada.
|
||||
|
||||
**Entregue:** `src/utilities/stateDeputyListUrl.ts` + `stateDeputyListFilters.ts` (contrato de URL/filtro, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido"); `stateDeputyData.ts` com `where`/`sort` resolvidos e facet de partido (`loadStateDeputyPartyFacet`, mesmo contrato de `loadMunicipalityListFilterFacets` — respeita a busca, ignora o próprio filtro do popover); `StateDeputySortableHead`/`StateDeputyHeaderFilter`/`StateDeputyFilters` (client, wrappers finos sobre `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover`); `dobradinhas/page.tsx` com redirect canônico, `CampaignTable` sempre montada (`empty={…}`) e resumo de ordenação `aria-live`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, conforme travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); `knip` com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, não introduzido por esta entrega); Aikido 0 achados nos 10 arquivos novos/editados.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`) e o par compartilhado que **B29** promove para `shared/`.
|
||||
|
|
@ -112,6 +130,7 @@ Componentes:
|
|||
## Adiado com gatilho
|
||||
|
||||
- **Sort/filtro por `municipalityCount`/`leadershipCount`.** Revisitar quando: houver pedido nomeado de produto para "dobradinhas sem nenhuma liderança/município" como recorte de onboarding, ou quando outro item já precisar do mesmo agregado SQL sobre `stateDeputy` (ex.: um "C8-like" de escala).
|
||||
- **Agregado SQL para o facet de partido em vez de escanear `stateDeputy` inteiro.** O facet hoje faz um `payload.find` de todos os registros (respeitando `q`) e agrega `party` em memória — só compensa a complexidade quando o volume de dobradinhas crescer o bastante para o full scan pesar (achado do `/simplify` B33 rodada 1, 2026-07-26). Revisitar quando: `stateDeputy` passar a ter volume comparável a `supporter`/`leadership` (centenas+), ou quando outro facet de lista já precisar do mesmo padrão de agregado (o `COUNT(*) FILTER` de `supporterListOverviewAggregate.ts`, do C6 ✓, é o precedente a seguir).
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -4,38 +4,90 @@ import Link from 'next/link'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
CampaignTransitionAnchor,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
|
||||
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
buildStateDeputyListHref,
|
||||
loadStateDeputyListPageData,
|
||||
parseStateDeputyListParams,
|
||||
type StateDeputyRowViewModel,
|
||||
} from '@/utilities/stateDeputyData'
|
||||
import {
|
||||
clearStateDeputyListFilters,
|
||||
type StateDeputyFilterOption,
|
||||
} from '@/utilities/stateDeputyListFilters'
|
||||
import {
|
||||
buildStateDeputyListHref,
|
||||
formatStateDeputyListSortSummary,
|
||||
resolveStateDeputyListSort,
|
||||
resolveStateDeputyListUrl,
|
||||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
type StateDeputiesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
const stateDeputyColumns: Array<CampaignTableColumn<StateDeputyRowViewModel>> = [
|
||||
const StateDeputyListEmptyState = ({ state }: { state: StateDeputyListState }) => (
|
||||
<Empty className="min-h-56">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma dobradinha encontrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca ou os filtros para encontrar as dobradinhas.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
|
||||
<CampaignTransitionAnchor
|
||||
href={buildStateDeputyListHref(clearStateDeputyListFilters(state), 1)}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
|
||||
>
|
||||
Limpar busca e filtros
|
||||
</CampaignTransitionAnchor>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
|
||||
const stateDeputyColumns = (
|
||||
state: StateDeputyListState,
|
||||
partyFilterOptions: StateDeputyFilterOption[],
|
||||
hasNoPartyOption: boolean,
|
||||
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
mandatory: true,
|
||||
head: <CampaignTableHead>Nome</CampaignTableHead>,
|
||||
head: (
|
||||
<StateDeputySortableHead state={state} sortKey="name">
|
||||
Nome
|
||||
</StateDeputySortableHead>
|
||||
),
|
||||
cell: (row) => (
|
||||
<Link
|
||||
href={`/campanha/dobradinhas/${row.slug}`}
|
||||
|
|
@ -47,7 +99,16 @@ const stateDeputyColumns: Array<CampaignTableColumn<StateDeputyRowViewModel>> =
|
|||
},
|
||||
{
|
||||
id: 'party',
|
||||
head: <CampaignTableHead>Partido</CampaignTableHead>,
|
||||
head: (
|
||||
<StateDeputySortableHead
|
||||
state={state}
|
||||
sortKey="party"
|
||||
filterOptions={partyFilterOptions}
|
||||
hasNoPartyOption={hasNoPartyOption}
|
||||
>
|
||||
Partido
|
||||
</StateDeputySortableHead>
|
||||
),
|
||||
cellClassName: 'text-muted-foreground',
|
||||
cell: (row) => row.party ?? '—',
|
||||
},
|
||||
|
|
@ -67,12 +128,25 @@ const stateDeputyColumns: Array<CampaignTableColumn<StateDeputyRowViewModel>> =
|
|||
|
||||
export default async function StateDeputiesPage({ searchParams }: StateDeputiesPageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const canonicalUrl = resolveStateDeputyListUrl(rawSearchParams)
|
||||
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
|
||||
|
||||
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
|
||||
if (!user) redirect('/campanha/login')
|
||||
if (!isCampaignStaff(user)) redirect('/campanha')
|
||||
|
||||
const state = parseStateDeputyListParams(rawSearchParams)
|
||||
const { rows, totalDocs, totalPages } = await loadStateDeputyListPageData(payload, user, state)
|
||||
const { rows, totalDocs, totalPages, filterFacets } = await loadStateDeputyListPageData(
|
||||
payload,
|
||||
user,
|
||||
canonicalUrl.state,
|
||||
)
|
||||
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
||||
const { sort, dir } = resolveStateDeputyListSort(state)
|
||||
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
|
||||
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
|
|
@ -93,40 +167,33 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
</header>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignSearchForm
|
||||
ariaLabel="Buscar dobradinha por nome"
|
||||
placeholder="Buscar por nome…"
|
||||
initialQuery={state.q ?? ''}
|
||||
basePath="/campanha/dobradinhas"
|
||||
<StateDeputyFilters
|
||||
state={state}
|
||||
partyOptions={partyFilterOptions}
|
||||
hasNoParty={filterFacets.hasNoParty}
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{sortSummary}
|
||||
</p>
|
||||
<CampaignTable
|
||||
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
|
||||
columns={stateDeputyColumns(state, partyFilterOptions, filterFacets.hasNoParty)}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
empty={<StateDeputyListEmptyState state={state} />}
|
||||
/>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<CampaignTable columns={stateDeputyColumns} rows={rows} rowKey={(row) => row.id} />
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="dobradinha"
|
||||
plural="dobradinhas"
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => buildStateDeputyListHref(state, page)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CampaignListEmptyState
|
||||
icon={SearchXIcon}
|
||||
title="Nenhuma dobradinha cadastrada"
|
||||
description="Cadastre deputados estaduais parceiros para vincular a municípios e lideranças."
|
||||
>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/dobradinhas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova dobradinha
|
||||
</Link>
|
||||
</Button>
|
||||
</CampaignListEmptyState>
|
||||
)}
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="dobradinha"
|
||||
plural="dobradinhas"
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => buildStateDeputyListHref(state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
174
src/components/campaign/stateDeputy/StateDeputyFilters.tsx
Normal file
174
src/components/campaign/stateDeputy/StateDeputyFilters.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
|
||||
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { normalizedText } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
buildStateDeputyPartyOptions,
|
||||
clearStateDeputyListFilters,
|
||||
formatStateDeputyActiveFiltersSummary,
|
||||
toggleStateDeputyPartyFilter,
|
||||
type StateDeputyFilterOption,
|
||||
} from '@/utilities/stateDeputyListFilters'
|
||||
import {
|
||||
buildStateDeputyListHref,
|
||||
parseStateDeputyListParams,
|
||||
parseStateDeputySortValue,
|
||||
resolveStateDeputyListSort,
|
||||
serializeStateDeputySortValue,
|
||||
stateDeputyListSortOptions,
|
||||
stateDeputyListStateToRawParams,
|
||||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 1000
|
||||
|
||||
export const StateDeputyFilters = ({
|
||||
state,
|
||||
partyOptions,
|
||||
hasNoParty,
|
||||
}: {
|
||||
state: StateDeputyListState
|
||||
/** Distinct party names present under the current search (server-computed facet). */
|
||||
partyOptions: StateDeputyFilterOption[]
|
||||
/** Whether at least one facet-matching row has no party — gates the "Sem partido" option. */
|
||||
hasNoParty: boolean
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState(state.q ?? '')
|
||||
const sharedPending = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
const isPending = sharedPending?.isPending ?? isLocalPending
|
||||
const startTransition = sharedPending?.startTransition ?? startLocalTransition
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { sort, dir } = resolveStateDeputyListSort(state)
|
||||
const mobilePartyOptions = buildStateDeputyPartyOptions(partyOptions, hasNoParty)
|
||||
const activeSummary = formatStateDeputyActiveFiltersSummary({
|
||||
...state,
|
||||
q: normalizedText(search) || state.q,
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const clearDebounce = () => {
|
||||
if (!debounceRef.current) return
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
|
||||
const navigateTo = (next: StateDeputyListState) => {
|
||||
clearDebounce()
|
||||
const canonical = parseStateDeputyListParams(
|
||||
stateDeputyListStateToRawParams({ ...next, q: normalizedText(next.q), page: 1 }, 1),
|
||||
)
|
||||
const nextHref = buildStateDeputyListHref(canonical, 1)
|
||||
if (nextHref === buildStateDeputyListHref(state, 1)) return
|
||||
startTransition(() => {
|
||||
router.replace(nextHref, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleSearch = (value: string) => {
|
||||
clearDebounce()
|
||||
if (normalizedText(value) === state.q) return
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null
|
||||
navigateTo({ ...state, q: value, page: 1 })
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending}
|
||||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigateTo({ ...state, q: search, page: 1 })
|
||||
}}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<CampaignSearchInput
|
||||
id="state-deputy-search"
|
||||
label="Buscar dobradinha"
|
||||
placeholder="Buscar por nome…"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value)
|
||||
scheduleSearch(event.target.value)
|
||||
}}
|
||||
/>
|
||||
{activeSummary ? (
|
||||
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
|
||||
{activeSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{activeSummary ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 md:self-end"
|
||||
onClick={() => {
|
||||
clearDebounce()
|
||||
setSearch('')
|
||||
navigateTo(clearStateDeputyListFilters(state))
|
||||
}}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{activeSummary ? <p className="text-sm text-muted-foreground">{activeSummary}</p> : null}
|
||||
<CampaignMobileMultiFilterField
|
||||
id="state-deputy-filter-party"
|
||||
label="Partido"
|
||||
emptyLabel="Todos"
|
||||
options={mobilePartyOptions}
|
||||
selected={state.parties ?? []}
|
||||
onToggle={(value) => navigateTo(toggleStateDeputyPartyFilter(state, value))}
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="state-deputy-sort">Ordenar</FieldLabel>
|
||||
<NativeSelect
|
||||
id="state-deputy-sort"
|
||||
value={serializeStateDeputySortValue(sort, dir)}
|
||||
onChange={(event) => {
|
||||
const parsed = parseStateDeputySortValue(event.target.value)
|
||||
if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir, page: 1 })
|
||||
}}
|
||||
className="w-full [&_select]:h-11"
|
||||
>
|
||||
{stateDeputyListSortOptions.map((option) => (
|
||||
<NativeSelectOption
|
||||
key={serializeStateDeputySortValue(option.key, option.dir)}
|
||||
value={serializeStateDeputySortValue(option.key, option.dir)}
|
||||
>
|
||||
{option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
'use client'
|
||||
|
||||
import { useOptimistic } from 'react'
|
||||
|
||||
import {
|
||||
CampaignHeaderFilterPopover,
|
||||
type CampaignHeaderFilterRow,
|
||||
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
|
||||
import {
|
||||
buildStateDeputyPartyOptions,
|
||||
clearStateDeputyPartyFilter,
|
||||
isStateDeputyPartyFilterActive,
|
||||
toggleStateDeputyPartyFilter,
|
||||
type StateDeputyFilterOption,
|
||||
} from '@/utilities/stateDeputyListFilters'
|
||||
import { buildStateDeputyListHref, type StateDeputyListState } from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
export const StateDeputyHeaderFilter = ({
|
||||
state,
|
||||
options,
|
||||
hasNoParty,
|
||||
}: {
|
||||
state: StateDeputyListState
|
||||
/** Distinct party names present under the current search (server-computed facet). */
|
||||
options: StateDeputyFilterOption[]
|
||||
/** Whether at least one facet-matching row has no party — gates the "Sem partido" row. */
|
||||
hasNoParty: boolean
|
||||
}) => {
|
||||
const [viewState, setOptimisticState] = useOptimistic(state)
|
||||
const active = isStateDeputyPartyFilterActive(viewState)
|
||||
|
||||
const allOptions = buildStateDeputyPartyOptions(options, hasNoParty)
|
||||
|
||||
const rows: CampaignHeaderFilterRow[] = allOptions.map((option) => {
|
||||
const next = toggleStateDeputyPartyFilter(viewState, option.value)
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: buildStateDeputyListHref(next, 1),
|
||||
selected: Boolean(viewState.parties?.includes(option.value)),
|
||||
checkbox: true,
|
||||
onChoose: () => setOptimisticState(next),
|
||||
}
|
||||
})
|
||||
|
||||
const clearedState = clearStateDeputyPartyFilter(viewState)
|
||||
|
||||
return (
|
||||
<CampaignHeaderFilterPopover
|
||||
id="state-deputy-filter-party"
|
||||
label="Partido"
|
||||
active={active}
|
||||
optionRows={rows}
|
||||
clear={
|
||||
active
|
||||
? {
|
||||
href: buildStateDeputyListHref(clearedState, 1),
|
||||
onChoose: () => setOptimisticState(clearedState),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
|
||||
import { StateDeputyHeaderFilter } from '@/components/campaign/stateDeputy/StateDeputyHeaderFilter'
|
||||
import type { StateDeputyFilterOption } from '@/utilities/stateDeputyListFilters'
|
||||
import {
|
||||
buildStateDeputySortHref,
|
||||
defaultStateDeputyListSortDir,
|
||||
resolveStateDeputyListSort,
|
||||
stateDeputyListSortLabels,
|
||||
type StateDeputyListSortKey,
|
||||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
type StateDeputySortableHeadProps = {
|
||||
state: StateDeputyListState
|
||||
sortKey: StateDeputyListSortKey
|
||||
children?: ReactNode
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
} &
|
||||
// Only one filterable column exists (Partido) — presence of `filterOptions`
|
||||
// is the discriminant, unlike Territory's region|coverage `filterParam`.
|
||||
(| { filterOptions: StateDeputyFilterOption[]; hasNoPartyOption: boolean }
|
||||
| { filterOptions?: undefined; hasNoPartyOption?: undefined }
|
||||
)
|
||||
|
||||
export const StateDeputySortableHead = ({
|
||||
state,
|
||||
sortKey,
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
filterOptions,
|
||||
hasNoPartyOption,
|
||||
}: StateDeputySortableHeadProps) => {
|
||||
const { sort: activeSort, dir } = resolveStateDeputyListSort(state)
|
||||
const active = activeSort === sortKey
|
||||
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultStateDeputyListSortDir()
|
||||
|
||||
return (
|
||||
<CampaignSortableHead
|
||||
active={active}
|
||||
align={align}
|
||||
className={className}
|
||||
dir={dir}
|
||||
href={buildStateDeputySortHref(state, sortKey)}
|
||||
nextDir={nextDir}
|
||||
sortLabel={stateDeputyListSortLabels[sortKey]}
|
||||
filter={
|
||||
filterOptions ? (
|
||||
<StateDeputyHeaderFilter
|
||||
state={state}
|
||||
options={filterOptions}
|
||||
hasNoParty={hasNoPartyOption}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{children ?? stateDeputyListSortLabels[sortKey]}
|
||||
</CampaignSortableHead>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,6 +30,10 @@ export const parseExhaustiveEnumParam = <T extends string>(
|
|||
return values.length < allowed.size ? values : []
|
||||
}
|
||||
|
||||
/** "A, B" for ≤2 names, "A, B +N" beyond — the active-filters summary shorthand. */
|
||||
export const truncatedNamesLabel = (names: readonly string[]): string =>
|
||||
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
|
||||
|
||||
export const normalizedText = (value: string | undefined): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const normalized = value.trim()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
municipalityKindLabels,
|
||||
municipalityListCoverageLabels,
|
||||
|
|
@ -310,10 +311,6 @@ export const isMunicipalityColumnFilterActive = (
|
|||
}
|
||||
}
|
||||
|
||||
/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */
|
||||
const firstNamesLabel = (names: string[]): string =>
|
||||
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
|
||||
|
||||
export const formatMunicipalityActiveFiltersSummary = (
|
||||
state: MunicipalityListState,
|
||||
): string | null => {
|
||||
|
|
@ -321,10 +318,12 @@ export const formatMunicipalityActiveFiltersSummary = (
|
|||
if (state.priority) parts.push(municipalityPriorityLabels.alta)
|
||||
if (state.slugs?.length) {
|
||||
parts.push(
|
||||
firstNamesLabel(state.slugs.map((slug) => getMunicipalityCatalogEntry(slug)?.name ?? slug)),
|
||||
truncatedNamesLabel(
|
||||
state.slugs.map((slug) => getMunicipalityCatalogEntry(slug)?.name ?? slug),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.regions?.length) parts.push(firstNamesLabel([...state.regions]))
|
||||
if (state.regions?.length) parts.push(truncatedNamesLabel([...state.regions]))
|
||||
if (state.kind) parts.push(municipalityKindLabels[state.kind])
|
||||
if (state.advisors?.length) {
|
||||
parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
|
||||
|
|
|
|||
|
|
@ -3,16 +3,15 @@ import 'server-only'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types'
|
||||
import {
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
const stateDeputyPageSize = 25
|
||||
import {
|
||||
buildStateDeputyListWhere,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
resolveStateDeputyListPayloadSort,
|
||||
resolveStateDeputyListSort,
|
||||
stateDeputyPageSize,
|
||||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
export type StateDeputyRowViewModel = {
|
||||
id: number
|
||||
|
|
@ -23,47 +22,75 @@ export type StateDeputyRowViewModel = {
|
|||
leadershipCount: number
|
||||
}
|
||||
|
||||
export type StateDeputyListState = {
|
||||
page: number
|
||||
q?: string
|
||||
/** Values still reachable under the OTHER active filters (the Partido popover). */
|
||||
export type StateDeputyListFilterFacets = {
|
||||
parties: string[]
|
||||
hasNoParty: boolean
|
||||
}
|
||||
|
||||
export const parseStateDeputyListParams = (searchParams: RawSearchParams): StateDeputyListState => {
|
||||
const q = normalizedText(firstValue(searchParams.q))
|
||||
/**
|
||||
* Respects the search (an OTHER filter, from the popover's point of view) but
|
||||
* drops the party filter itself — same contract as
|
||||
* `loadMunicipalityListFilterFacets`: a selected value is unioned in so it
|
||||
* stays visible to undo even if the search would otherwise hide it.
|
||||
*/
|
||||
const loadStateDeputyPartyFacet = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: StateDeputyListState,
|
||||
): Promise<StateDeputyListFilterFacets> => {
|
||||
const result = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: buildStateDeputyListWhere({ ...state, parties: undefined }),
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { party: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const availableParties = new Set<string>(
|
||||
(state.parties ?? []).filter((party) => party !== NO_PARTY_FILTER_VALUE),
|
||||
)
|
||||
let hasNoParty = (state.parties ?? []).includes(NO_PARTY_FILTER_VALUE)
|
||||
|
||||
for (const doc of result.docs) {
|
||||
if (doc.party) availableParties.add(doc.party)
|
||||
else hasNoParty = true
|
||||
}
|
||||
|
||||
return {
|
||||
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
parties: [...availableParties].sort((left, right) => left.localeCompare(right, 'pt-BR')),
|
||||
hasNoParty,
|
||||
}
|
||||
}
|
||||
|
||||
const buildStateDeputyListSearchParams = (
|
||||
state: StateDeputyListState,
|
||||
page = state.page,
|
||||
): URLSearchParams => {
|
||||
const params = new URLSearchParams()
|
||||
if (state.q) params.set('q', state.q)
|
||||
if (page > 1) params.set('page', String(page))
|
||||
return params
|
||||
}
|
||||
|
||||
export const buildStateDeputyListHref = (state: StateDeputyListState, page: number): string =>
|
||||
buildListHref(state, buildStateDeputyListSearchParams, '/campanha/dobradinhas', page)
|
||||
|
||||
export const loadStateDeputyListPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: StateDeputyListState,
|
||||
): Promise<{ rows: StateDeputyRowViewModel[]; totalDocs: number; totalPages: number }> => {
|
||||
const result = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: state.q ? { name: { contains: state.q } } : {},
|
||||
depth: 0,
|
||||
limit: stateDeputyPageSize,
|
||||
page: state.page,
|
||||
sort: 'name',
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
): Promise<{
|
||||
rows: StateDeputyRowViewModel[]
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
filterFacets: StateDeputyListFilterFacets
|
||||
}> => {
|
||||
const { sort, dir } = resolveStateDeputyListSort(state)
|
||||
const [result, filterFacets] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: buildStateDeputyListWhere(state),
|
||||
depth: 0,
|
||||
limit: stateDeputyPageSize,
|
||||
page: state.page,
|
||||
sort: resolveStateDeputyListPayloadSort(sort, dir),
|
||||
select: { name: true, slug: true, party: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
loadStateDeputyPartyFacet(payload, user, state),
|
||||
])
|
||||
|
||||
const stateDeputyIDs = result.docs.map((doc) => doc.id)
|
||||
const municipalityCounts = new Map<number, number>()
|
||||
|
|
@ -121,6 +148,7 @@ export const loadStateDeputyListPageData = async (
|
|||
})),
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
filterFacets,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
85
src/utilities/stateDeputyListFilters.ts
Normal file
85
src/utilities/stateDeputyListFilters.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* State deputy ("dobradinha") list filter affordances: Partido toggle, active
|
||||
* filter summary and clear-filters. Mirrors `territoryListFilters.ts`; only
|
||||
* one filterable column exists, so there is no B16+-style href fast path —
|
||||
* `stateDeputy` runs in the dozens of rows, not 435.
|
||||
*/
|
||||
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
parseStateDeputyListParams,
|
||||
stateDeputyListStateToRawParams,
|
||||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
export type StateDeputyFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const noPartyFilterLabel = 'Sem partido'
|
||||
|
||||
/**
|
||||
* Appends the "Sem partido" sentinel row to the facet-derived options —
|
||||
* shared by the desktop popover (`StateDeputyHeaderFilter`) and the mobile
|
||||
* filter bar (`StateDeputyFilters`) so the row is built once, not twice.
|
||||
*/
|
||||
export const buildStateDeputyPartyOptions = (
|
||||
options: readonly StateDeputyFilterOption[],
|
||||
hasNoParty: boolean,
|
||||
): StateDeputyFilterOption[] =>
|
||||
hasNoParty
|
||||
? [...options, { value: NO_PARTY_FILTER_VALUE, label: noPartyFilterLabel }]
|
||||
: [...options]
|
||||
|
||||
export const toggleStateDeputyPartyFilter = (
|
||||
state: StateDeputyListState,
|
||||
value: string,
|
||||
): StateDeputyListState => {
|
||||
const current = state.parties ?? []
|
||||
const parties = current.includes(value)
|
||||
? current.filter((party) => party !== value)
|
||||
: [...current, value]
|
||||
return parseStateDeputyListParams({
|
||||
...stateDeputyListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
party: parties,
|
||||
})
|
||||
}
|
||||
|
||||
/** Per-column clear (the header popover's "Limpar") — keeps the search and sort. */
|
||||
export const clearStateDeputyPartyFilter = (state: StateDeputyListState): StateDeputyListState =>
|
||||
parseStateDeputyListParams({
|
||||
...stateDeputyListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
party: undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* The global "Limpar" contract shared by the mobile filter bar and the empty
|
||||
* state: drop every filter and the search, keep the sort — same shape as
|
||||
* `clearMunicipalityListFilters`.
|
||||
*/
|
||||
export const clearStateDeputyListFilters = (state: StateDeputyListState): StateDeputyListState => ({
|
||||
page: 1,
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
})
|
||||
|
||||
export const isStateDeputyPartyFilterActive = (state: StateDeputyListState): boolean =>
|
||||
Boolean(state.parties?.length)
|
||||
|
||||
export const formatStateDeputyActiveFiltersSummary = (
|
||||
state: StateDeputyListState,
|
||||
): string | null => {
|
||||
const parts: string[] = []
|
||||
if (state.parties?.length) {
|
||||
parts.push(
|
||||
truncatedNamesLabel(
|
||||
state.parties.map((party) =>
|
||||
party === NO_PARTY_FILTER_VALUE ? noPartyFilterLabel : party,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
}
|
||||
231
src/utilities/stateDeputyListUrl.ts
Normal file
231
src/utilities/stateDeputyListUrl.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* State deputy ("dobradinha") list URL contract: state type, param
|
||||
* parsing/canonicalization, Payload `where`, sort resolution and hrefs.
|
||||
* Mirrors `municipalityListUrl.ts` (paginated, `resolveListUrl` clamp) and
|
||||
* `territoryListUrl.ts` (lean sort-key/label shape) without importing either
|
||||
* (B29's `leadershipListUrl.ts` doesn't exist yet — nothing to mirror there).
|
||||
*/
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
resolveListUrl,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
|
||||
export const stateDeputyPageSize = 25
|
||||
|
||||
export type StateDeputyListSortKey = 'name' | 'party'
|
||||
export type StateDeputyListSortDirection = 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Sentinel for the "Sem partido" filter row — `party` is a free-text optional
|
||||
* field (no closed enum to validate a real value against), so this constant
|
||||
* is the only reserved token; a dobradinha whose actual party name collided
|
||||
* with it would be indistinguishable, an accepted precedent-level risk (the
|
||||
* curated field spans a couple dozen Brazilian party acronyms).
|
||||
*/
|
||||
export const NO_PARTY_FILTER_VALUE = 'sem_partido'
|
||||
|
||||
export type StateDeputyListState = {
|
||||
page: number
|
||||
q?: string
|
||||
/** Multi-select (OR) party names, plus the `NO_PARTY_FILTER_VALUE` sentinel. */
|
||||
parties?: string[]
|
||||
sort?: StateDeputyListSortKey
|
||||
dir?: StateDeputyListSortDirection
|
||||
}
|
||||
|
||||
export type StateDeputyListSearchParams = RawSearchParams
|
||||
|
||||
const stateDeputyListParamNames = ['q', 'party', 'sort', 'dir', 'page'] as const
|
||||
const stateDeputyListParamNameSet = new Set<string>(stateDeputyListParamNames)
|
||||
|
||||
const DEFAULT_STATE_DEPUTY_LIST_SORT_KEY: StateDeputyListSortKey = 'name'
|
||||
const DEFAULT_STATE_DEPUTY_LIST_SORT_DIR: StateDeputyListSortDirection = 'asc'
|
||||
|
||||
export const stateDeputyListSortLabels: Record<StateDeputyListSortKey, string> = {
|
||||
name: 'Nome',
|
||||
party: 'Partido',
|
||||
}
|
||||
|
||||
const stateDeputyListSortKeySet = new Set<string>(Object.keys(stateDeputyListSortLabels))
|
||||
const stateDeputyListSortDirSet = new Set<StateDeputyListSortDirection>(['asc', 'desc'])
|
||||
|
||||
/** Both sort keys default to ascending (A–Z) — no "biggest first" semantics here. */
|
||||
export const defaultStateDeputyListSortDir = (): StateDeputyListSortDirection =>
|
||||
DEFAULT_STATE_DEPUTY_LIST_SORT_DIR
|
||||
|
||||
export const resolveStateDeputyListSort = (
|
||||
state: StateDeputyListState,
|
||||
): { sort: StateDeputyListSortKey; dir: StateDeputyListSortDirection } => {
|
||||
const sort = state.sort ?? DEFAULT_STATE_DEPUTY_LIST_SORT_KEY
|
||||
return { sort, dir: state.dir ?? defaultStateDeputyListSortDir() }
|
||||
}
|
||||
|
||||
/** `payload.find({ sort })` string — `-` prefix for descending. */
|
||||
export const resolveStateDeputyListPayloadSort = (
|
||||
sort: StateDeputyListSortKey,
|
||||
dir: StateDeputyListSortDirection,
|
||||
): string => (dir === 'desc' ? `-${sort}` : sort)
|
||||
|
||||
export const formatStateDeputyListSortSummary = (
|
||||
sort: StateDeputyListSortKey,
|
||||
dir: StateDeputyListSortDirection,
|
||||
): string => {
|
||||
const label = stateDeputyListSortLabels[sort]
|
||||
return dir === 'asc' ? `Ordenado por ${label} (A–Z)` : `Ordenado por ${label} (Z–A)`
|
||||
}
|
||||
|
||||
export const stateDeputyListStateToRawParams = (
|
||||
state: StateDeputyListState,
|
||||
page = state.page,
|
||||
): StateDeputyListSearchParams => ({
|
||||
page: String(page),
|
||||
q: state.q,
|
||||
party: state.parties,
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
})
|
||||
|
||||
export const parseStateDeputyListParams = (
|
||||
params: StateDeputyListSearchParams,
|
||||
): StateDeputyListState => {
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const q = normalizedText(firstValue(params.q))
|
||||
// `allParamValues` already trims and dedupes; only the length cap is ours.
|
||||
const parties = allParamValues(params.party).filter((token) => token.length <= 32)
|
||||
const rawSort = firstValue(params.sort)
|
||||
const sort =
|
||||
rawSort && stateDeputyListSortKeySet.has(rawSort)
|
||||
? (rawSort as StateDeputyListSortKey)
|
||||
: undefined
|
||||
const rawDir = firstValue(params.dir)
|
||||
const dir =
|
||||
rawDir && stateDeputyListSortDirSet.has(rawDir as StateDeputyListSortDirection)
|
||||
? (rawDir as StateDeputyListSortDirection)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
page: rawPage ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
...(parties.length ? { parties } : {}),
|
||||
...(sort ? { sort } : {}),
|
||||
...(dir ? { dir } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildStateDeputyListWhere = (state: StateDeputyListState): Where => {
|
||||
const filters: Where[] = []
|
||||
if (state.q) filters.push({ name: { contains: state.q } })
|
||||
|
||||
if (state.parties?.length) {
|
||||
const namedParties = state.parties.filter((party) => party !== NO_PARTY_FILTER_VALUE)
|
||||
const includesNoParty = state.parties.includes(NO_PARTY_FILTER_VALUE)
|
||||
const partyFilters: Where[] = []
|
||||
if (namedParties.length) partyFilters.push({ party: { in: namedParties } })
|
||||
if (includesNoParty) partyFilters.push({ party: { exists: false } })
|
||||
// Every entry in a non-empty `parties` is named or the sentinel (or both),
|
||||
// so `partyFilters` always has at least one member here.
|
||||
filters.push(partyFilters.length > 1 ? { or: partyFilters } : partyFilters[0])
|
||||
}
|
||||
|
||||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a state that is ALREADY canonical (came out of
|
||||
* `parseStateDeputyListParams`, or was derived from a canonical state by a
|
||||
* rule-preserving toggle) — same private-ish contract as
|
||||
* `serializeCanonicalMunicipalityListSearchParams`.
|
||||
*/
|
||||
export const serializeCanonicalStateDeputyListSearchParams = (
|
||||
canonicalState: StateDeputyListState,
|
||||
): URLSearchParams => {
|
||||
const params = new URLSearchParams()
|
||||
const resolvedSort = canonicalState.sort ?? DEFAULT_STATE_DEPUTY_LIST_SORT_KEY
|
||||
const resolvedDir = canonicalState.dir ?? defaultStateDeputyListSortDir()
|
||||
const isSortDefault =
|
||||
resolvedSort === DEFAULT_STATE_DEPUTY_LIST_SORT_KEY &&
|
||||
resolvedDir === DEFAULT_STATE_DEPUTY_LIST_SORT_DIR
|
||||
|
||||
if (canonicalState.q) params.set('q', canonicalState.q)
|
||||
for (const party of canonicalState.parties ?? []) params.append('party', party)
|
||||
if (!isSortDefault) {
|
||||
params.set('sort', resolvedSort)
|
||||
if (resolvedDir !== DEFAULT_STATE_DEPUTY_LIST_SORT_DIR) params.set('dir', resolvedDir)
|
||||
}
|
||||
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const buildStateDeputyListSearchParams = (
|
||||
state: StateDeputyListState,
|
||||
page = state.page,
|
||||
): URLSearchParams =>
|
||||
serializeCanonicalStateDeputyListSearchParams(
|
||||
parseStateDeputyListParams(stateDeputyListStateToRawParams(state, page)),
|
||||
)
|
||||
|
||||
export const buildStateDeputyListHref = (state: StateDeputyListState, page: number): string =>
|
||||
buildListHref(state, buildStateDeputyListSearchParams, '/campanha/dobradinhas', page)
|
||||
|
||||
export const buildStateDeputySortHref = (
|
||||
state: StateDeputyListState,
|
||||
nextKey: StateDeputyListSortKey,
|
||||
): string => {
|
||||
const current = resolveStateDeputyListSort(state)
|
||||
const dir =
|
||||
current.sort === nextKey
|
||||
? current.dir === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: DEFAULT_STATE_DEPUTY_LIST_SORT_DIR
|
||||
|
||||
return buildStateDeputyListHref({ ...state, sort: nextKey, dir, page: 1 }, 1)
|
||||
}
|
||||
|
||||
const sortOptionLabel = (key: StateDeputyListSortKey, dir: StateDeputyListSortDirection): string =>
|
||||
`${stateDeputyListSortLabels[key]} (${dir === 'asc' ? 'A–Z' : 'Z–A'})`
|
||||
|
||||
export const stateDeputyListSortOptions = (
|
||||
Object.keys(stateDeputyListSortLabels) as StateDeputyListSortKey[]
|
||||
).flatMap((key) =>
|
||||
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: sortOptionLabel(key, dir) })),
|
||||
)
|
||||
|
||||
export const serializeStateDeputySortValue = (
|
||||
key: StateDeputyListSortKey,
|
||||
dir: StateDeputyListSortDirection,
|
||||
): string => `${key}|${dir}`
|
||||
|
||||
export const parseStateDeputySortValue = (
|
||||
value: string,
|
||||
): { key: StateDeputyListSortKey; dir: StateDeputyListSortDirection } | null => {
|
||||
const [key, dir] = value.split('|')
|
||||
if (!stateDeputyListSortKeySet.has(key)) return null
|
||||
if (!stateDeputyListSortDirSet.has(dir as StateDeputyListSortDirection)) return null
|
||||
return { key: key as StateDeputyListSortKey, dir: dir as StateDeputyListSortDirection }
|
||||
}
|
||||
|
||||
export const resolveStateDeputyListUrl = (
|
||||
params: StateDeputyListSearchParams,
|
||||
totalPages?: number,
|
||||
): {
|
||||
state: StateDeputyListState
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} =>
|
||||
resolveListUrl({
|
||||
params,
|
||||
paramNameSet: stateDeputyListParamNameSet,
|
||||
parse: parseStateDeputyListParams,
|
||||
buildSearchParams: buildStateDeputyListSearchParams,
|
||||
basePath: '/campanha/dobradinhas',
|
||||
totalPages,
|
||||
})
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
parseTerritoryListParams,
|
||||
territoryListStateToRawParams,
|
||||
|
|
@ -54,12 +55,9 @@ export const isTerritoryFilterActive = (
|
|||
param: TerritoryFilterParam,
|
||||
): boolean => (param === 'region' ? Boolean(state.regions?.length) : Boolean(state.coverage))
|
||||
|
||||
const firstTerritoryNamesLabel = (names: readonly string[]): string =>
|
||||
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
|
||||
|
||||
export const formatTerritoryActiveFiltersSummary = (state: TerritoryListState): string | null => {
|
||||
const parts: string[] = []
|
||||
if (state.regions?.length) parts.push(firstTerritoryNamesLabel(state.regions))
|
||||
if (state.regions?.length) parts.push(truncatedNamesLabel(state.regions))
|
||||
if (state.coverage) parts.push(territoryCoverageLabels[state.coverage])
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { loadDemandListPageData } from '@/utilities/campaignDemandData'
|
|||
import { loadLeadershipListPageData } from '@/utilities/leadershipData'
|
||||
import { loadOrganizationListPageData } from '@/utilities/organizationData'
|
||||
import { loadStateDeputyListPageData } from '@/utilities/stateDeputyData'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
|
|
@ -134,6 +135,85 @@ describe('loadStateDeputyListPageData', () => {
|
|||
leadershipCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts by party in both directions, pinning where the party-less row falls', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const marker = fixtures.value('sort')
|
||||
const withPsd = await fixtures.createStateDeputy({
|
||||
name: `Dep ${marker} PSD`,
|
||||
party: 'PSD',
|
||||
})
|
||||
const withPt = await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' })
|
||||
const withoutParty = await fixtures.createStateDeputy({ name: `Dep ${marker} sem` })
|
||||
|
||||
// Pinned adapter behavior (Postgres default, unmodified by Payload): ASC
|
||||
// sorts nulls LAST, DESC sorts nulls FIRST — the party-less row never
|
||||
// lands in the middle. "Sem partido" is the direct, unambiguous path to
|
||||
// find it either way.
|
||||
const asc = await loadStateDeputyListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
sort: 'party',
|
||||
dir: 'asc',
|
||||
})
|
||||
expect(asc.rows.map((row) => row.slug)).toEqual([withPsd.slug, withPt.slug, withoutParty.slug])
|
||||
|
||||
const desc = await loadStateDeputyListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
sort: 'party',
|
||||
dir: 'desc',
|
||||
})
|
||||
expect(desc.rows.map((row) => row.slug)).toEqual([withoutParty.slug, withPt.slug, withPsd.slug])
|
||||
})
|
||||
|
||||
it('filters by selected parties and by the "Sem partido" sentinel', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const marker = fixtures.value('filter')
|
||||
const withPt = await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' })
|
||||
await fixtures.createStateDeputy({ name: `Dep ${marker} PSD`, party: 'PSD' })
|
||||
const withoutParty = await fixtures.createStateDeputy({ name: `Dep ${marker} sem` })
|
||||
|
||||
const byParty = await loadStateDeputyListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
parties: ['PT'],
|
||||
})
|
||||
expect(byParty.rows.map((row) => row.slug)).toEqual([withPt.slug])
|
||||
|
||||
const byNoParty = await loadStateDeputyListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
parties: [NO_PARTY_FILTER_VALUE],
|
||||
})
|
||||
expect(byNoParty.rows.map((row) => row.slug)).toEqual([withoutParty.slug])
|
||||
})
|
||||
|
||||
it('computes the party facet from the current search, ignoring the party filter itself', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const marker = fixtures.value('facet')
|
||||
await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' })
|
||||
await fixtures.createStateDeputy({ name: `Dep ${marker} PSD`, party: 'PSD' })
|
||||
await fixtures.createStateDeputy({ name: `Dep ${marker} sem` })
|
||||
|
||||
const unfiltered = await loadStateDeputyListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
})
|
||||
expect(unfiltered.filterFacets).toEqual({ parties: ['PSD', 'PT'], hasNoParty: true })
|
||||
|
||||
// Selecting PT keeps every party option reachable — the facet ignores its
|
||||
// own popover's filter so the other options remain visible to switch to.
|
||||
const filteredByPt = await loadStateDeputyListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
parties: ['PT'],
|
||||
})
|
||||
expect(filteredByPt.filterFacets).toEqual({ parties: ['PSD', 'PT'], hasNoParty: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadDemandListPageData', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
/**
|
||||
* Pins the five "simple" entity list parsers ahead of the Pass 2 list-system
|
||||
* consolidation (W1): lideranças, organizações, dobradinhas, demandas and
|
||||
* assessores. Their URL contracts must survive the migration byte-identical.
|
||||
* Pins the "simple" entity list parsers ahead of the Pass 2 list-system
|
||||
* consolidation (W1): lideranças, organizações, demandas and assessores.
|
||||
* Their URL contracts must survive the migration byte-identical. Dobradinhas
|
||||
* left this group in B33 — its parser grew a full sort/filter contract of its
|
||||
* own and moved to `stateDeputyListUrl.ts`, pinned in
|
||||
* `stateDeputyListUrl.unit.spec.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
|
|
@ -9,11 +12,9 @@ import { advisorListHrefForPage, parseAdvisorListParams } from '@/utilities/advi
|
|||
import { parseDemandListParams } from '@/utilities/campaignDemandData'
|
||||
import { parseLeadershipListParams } from '@/utilities/leadershipData'
|
||||
import { parseOrganizationListParams } from '@/utilities/organizationData'
|
||||
import { parseStateDeputyListParams } from '@/utilities/stateDeputyData'
|
||||
|
||||
const qAndPageParsers = [
|
||||
['leadership', parseLeadershipListParams],
|
||||
['stateDeputy', parseStateDeputyListParams],
|
||||
['advisor', parseAdvisorListParams],
|
||||
] as const
|
||||
|
||||
|
|
|
|||
152
tests/unit/stateDeputyListUrl.unit.spec.ts
Normal file
152
tests/unit/stateDeputyListUrl.unit.spec.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearStateDeputyListFilters,
|
||||
clearStateDeputyPartyFilter,
|
||||
formatStateDeputyActiveFiltersSummary,
|
||||
toggleStateDeputyPartyFilter,
|
||||
} from '@/utilities/stateDeputyListFilters'
|
||||
import {
|
||||
buildStateDeputyListHref,
|
||||
buildStateDeputySortHref,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
parseStateDeputyListParams,
|
||||
parseStateDeputySortValue,
|
||||
resolveStateDeputyListPayloadSort,
|
||||
resolveStateDeputyListSort,
|
||||
resolveStateDeputyListUrl,
|
||||
serializeCanonicalStateDeputyListSearchParams,
|
||||
serializeStateDeputySortValue,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
describe('state deputy list URL contract', () => {
|
||||
it('defaults to page 1 with no sort/filters', () => {
|
||||
expect(parseStateDeputyListParams({})).toEqual({ page: 1 })
|
||||
})
|
||||
|
||||
it('validates page as a positive decimal integer', () => {
|
||||
expect(parseStateDeputyListParams({ page: '4' }).page).toBe(4)
|
||||
expect(parseStateDeputyListParams({ page: '0' }).page).toBe(1)
|
||||
expect(parseStateDeputyListParams({ page: '-2' }).page).toBe(1)
|
||||
expect(parseStateDeputyListParams({ page: 'abc' }).page).toBe(1)
|
||||
})
|
||||
|
||||
it('trims q, omits it when empty and takes the first repeated cell', () => {
|
||||
expect(parseStateDeputyListParams({ q: ' ana ' }).q).toBe('ana')
|
||||
expect(parseStateDeputyListParams({ q: ' ' }).q).toBeUndefined()
|
||||
expect(parseStateDeputyListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
|
||||
})
|
||||
|
||||
it('parses and dedupes repeated party params, ignoring overlong tokens', () => {
|
||||
expect(
|
||||
parseStateDeputyListParams({
|
||||
party: ['PT', 'PSD', 'PT', 'a'.repeat(33)],
|
||||
}).parties,
|
||||
).toEqual(['PT', 'PSD'])
|
||||
})
|
||||
|
||||
it('keeps only known sort keys and directions', () => {
|
||||
expect(parseStateDeputyListParams({ sort: 'party', dir: 'desc' })).toEqual({
|
||||
page: 1,
|
||||
sort: 'party',
|
||||
dir: 'desc',
|
||||
})
|
||||
expect(parseStateDeputyListParams({ sort: 'unknown', dir: 'sideways' })).toEqual({ page: 1 })
|
||||
})
|
||||
|
||||
it('omits the default sort pair from the canonical query', () => {
|
||||
const state = parseStateDeputyListParams({ sort: 'name', dir: 'asc' })
|
||||
expect(serializeCanonicalStateDeputyListSearchParams(state).toString()).toBe('')
|
||||
expect(resolveStateDeputyListSort(state)).toEqual({ sort: 'name', dir: 'asc' })
|
||||
})
|
||||
|
||||
it('serializes a non-default sort pair and repeated party filters', () => {
|
||||
const state = parseStateDeputyListParams({
|
||||
q: 'edu',
|
||||
party: ['PT', 'PSD'],
|
||||
sort: 'party',
|
||||
dir: 'desc',
|
||||
})
|
||||
expect(buildStateDeputyListHref(state, 1)).toBe(
|
||||
'/campanha/dobradinhas?q=edu&party=PT&party=PSD&sort=party&dir=desc',
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves filters while toggling sort direction and resets to page 1', () => {
|
||||
const state = parseStateDeputyListParams({ q: 'edu', page: '3' })
|
||||
|
||||
expect(buildStateDeputySortHref(state, 'party')).toBe('/campanha/dobradinhas?q=edu&sort=party')
|
||||
expect(buildStateDeputySortHref({ ...state, sort: 'party' }, 'party')).toContain('dir=desc')
|
||||
})
|
||||
|
||||
it('resolves the payload sort string with the descending prefix', () => {
|
||||
expect(resolveStateDeputyListPayloadSort('name', 'asc')).toBe('name')
|
||||
expect(resolveStateDeputyListPayloadSort('party', 'desc')).toBe('-party')
|
||||
})
|
||||
|
||||
it('clamps an out-of-range page and redirects non-canonical query strings', () => {
|
||||
expect(resolveStateDeputyListUrl({ page: '9' }, 2)).toEqual({
|
||||
state: { page: 2 },
|
||||
href: '/campanha/dobradinhas?page=2',
|
||||
redirectHref: '/campanha/dobradinhas?page=2',
|
||||
})
|
||||
expect(resolveStateDeputyListUrl({ q: ' edu ', unknown: 'x' })).toEqual({
|
||||
state: { page: 1, q: 'edu' },
|
||||
href: '/campanha/dobradinhas?q=edu',
|
||||
redirectHref: '/campanha/dobradinhas?q=edu',
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips the mobile sort select value', () => {
|
||||
expect(serializeStateDeputySortValue('party', 'desc')).toBe('party|desc')
|
||||
expect(parseStateDeputySortValue('party|desc')).toEqual({ key: 'party', dir: 'desc' })
|
||||
expect(parseStateDeputySortValue('unknown|desc')).toBeNull()
|
||||
expect(parseStateDeputySortValue('party|sideways')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state deputy list filter state', () => {
|
||||
it('toggles the party filter and resets the page', () => {
|
||||
const withParty = toggleStateDeputyPartyFilter({ page: 3 }, 'PT')
|
||||
expect(withParty).toEqual({ page: 1, parties: ['PT'] })
|
||||
expect(toggleStateDeputyPartyFilter(withParty, 'PT')).toEqual({ page: 1 })
|
||||
expect(toggleStateDeputyPartyFilter(withParty, NO_PARTY_FILTER_VALUE)).toEqual({
|
||||
page: 1,
|
||||
parties: ['PT', NO_PARTY_FILTER_VALUE],
|
||||
})
|
||||
})
|
||||
|
||||
it('clears only the party filter, keeping q and sort', () => {
|
||||
expect(
|
||||
clearStateDeputyPartyFilter({
|
||||
page: 2,
|
||||
q: 'edu',
|
||||
parties: ['PT'],
|
||||
sort: 'party',
|
||||
dir: 'desc',
|
||||
}),
|
||||
).toEqual({ page: 1, q: 'edu', sort: 'party', dir: 'desc' })
|
||||
})
|
||||
|
||||
it('clears filters and search while preserving the selected sort', () => {
|
||||
expect(
|
||||
clearStateDeputyListFilters({
|
||||
page: 2,
|
||||
q: 'edu',
|
||||
parties: ['PT'],
|
||||
sort: 'party',
|
||||
dir: 'desc',
|
||||
}),
|
||||
).toEqual({ page: 1, sort: 'party', dir: 'desc' })
|
||||
})
|
||||
|
||||
it('formats a compact active-filter summary, translating the sentinel', () => {
|
||||
expect(
|
||||
formatStateDeputyActiveFiltersSummary({
|
||||
page: 1,
|
||||
q: 'edu',
|
||||
parties: ['PT', 'PSD', NO_PARTY_FILTER_VALUE],
|
||||
}),
|
||||
).toBe('PT, PSD +1 · Busca "edu"')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue