mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #477 from fsolla/B176-filtrar-municipios-por-dobradinha-lideranca-e-partido-n
B176 — Filtrar municípios por Dobradinha/Liderança/Partido + coluna Dobradinhas ao assessor
This commit is contained in:
commit
2270bfb84e
27 changed files with 1652 additions and 107 deletions
|
|
@ -4,13 +4,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
|
||||
|
||||
**Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md).
|
||||
**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
|
||||
|
||||
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
|
||||
|
|
|
|||
103
docs/plans/filtros-municipios-dobradinha-lideranca-impl.md
Normal file
103
docs/plans/filtros-municipios-dobradinha-lideranca-impl.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Impl: Filtrar municípios por Dobradinha, Liderança e Partido (+ liberar a coluna Dobradinhas ao Assessor)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #458
|
||||
Intenção: docs/plans/filtros-municipios-dobradinha-lideranca.md
|
||||
Appetite restante: ~1,5–2 dias eng (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** na omnibox de `/campanha/municipios`, **Dobradinha**, **Liderança** e **Partido (dobradinha)** viram dimensões de filtro (busca por nome/sigla + chips removíveis + recorte "Sem …"), no padrão do Assessor; Multi-valor OR por dimensão; parâmetros novos **aditivos** à URL congelada. A **coluna Dobradinhas** passa a ser vista e editada (inline) pelo **Assessor**, dentro dos municípios que ele administra.
|
||||
- **O que NÃO negociar:** URL congelada (B18) — mudança aditiva, nunca re-sêmantiza parâmetros existentes; leader lockdown (a página já tem `gate: noLeader`); facets/nomes nunca vazam escopo do ator; `estimatedVotes` fora de alcance (nenhum destes filtros toca estimativa); dados de Dobradinha são staff-wide (registro), mas o recorte do assessor nasce do portfólio administrado.
|
||||
- **O que reavaliar:** a intenção supõe "liderança necessita leitura reversa" e "partido via facel de leitura cruzada". Confirmei no código: `stateDeputy` **não** guarda `municipalities` (a relação vive só em `municipality.stateDeputies`, `index: true`), então **partido também exige pré-cálculo de conjuntos** (deputados do partido → municípios ligados), não só liderança. O filtro de dobradinha é o único direto (`stateDeputies: { in: [...] }`).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[municipalityListUrl<br/>novos params: stateDeputy, leadership, party + sentinelas] --> B[municipalityListFilters<br/>multi-params + summary + fast-path toggles]
|
||||
B --> C[municipalityOmnibox<br/>grupos Dobradinha/Liderança/Partido + Sem…]
|
||||
D[loadMunicipalityListRelationSets<br/>server-only: conjuntos pré-calculados] --> E[buildMunicipalityListWhere<br/>+ sets no where final]
|
||||
D --> F[loadMunicipalityListFilterFacets<br/>+ stateDeputyIDs/leadershipIDs/parties]
|
||||
F --> G[page + MunicipalityFilters<br/>opções rotuladas + maps id→nome]
|
||||
H[page: libera coluna ao staff<br/>stateDeputyOptions p/ todo staff] --> I[MunicipalityList + MobileCards<br/>coluna sem gate isCampaignUnrestricted]
|
||||
E --> J[municipalityMapData<br/>mapa compartilha o where]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Filtros expressos em `where` com conjuntos pré-calculados** (recomendada): `buildMunicipalityListWhere(state, sets?)` continua pura/síncrona e **fail-closed** quando `leadership`/`party` pedem conjuntos ausentes; conjuntos vêm de um helper server-only (`loadMunicipalityListRelationSets`) usado por página, facets e mapa. Dobradinha (direta) e os "Sem …" combinam sem sair de `where` (`exists: false`, `not_in`).
|
||||
- **B — Derivar em memória como `class`** (forçar o caminho load-tudo + filtro em JS): simples, mas descarta o paged-by-Payload para 3 dimensões novas (perde paginação nativa e `totalDocs` correto via DB) — pior custo na 435-linha.
|
||||
- **C — Campo espelho `stateDeputy.municipalities`** (hasMany espelhado mantido pelo mesmo hook que escreve `municipality.stateDeputies`): permitiria joins diretos para partido/liderança, mas é **migração** + hook de espelhamento novo + risco de drift fora do escopo do appetite ("sem migration" no plano da intenção).
|
||||
|
||||
**Recomendação:** A — sem migration, mantém o contrato de URL aditivo, mantém a paginação nativa (o `where` continua expresso), facets seguem a regra "valores ainda alcançáveis", e o custo novo é um conjunto pequeno de leituras únicas (sem N+1). C é cara e rejeitada; B é barata mas piora performance global da lista.
|
||||
**Rejeitadas:** B (introjeção em memória de 3 dimensões numa lista paginada); C (migração + campo espelho; fora do appetite declarado "sem migration").
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Parâmetros de URL (aditivos)** — `stateDeputy` (IDs numéricos multi, sentinela `sem_dobradinha`), `leadership` (IDs multi, sentinela `sem_lideranca`), `party` (texto multi, sentinela reusa `NO_PARTY_FILTER_VALUE = 'sem_partido'`). Nomes espelham B143 (`stateDeputy`) e `stateDeputyListUrl` (`party`). Estado: `stateDeputies?: number[]`, `leaderships?: number[]`, `parties?: string[]`.
|
||||
- **Decisão:** extrair o par "sentinela + branch `exists:false`" num helper compartilhado — o comentário de `municipalityListUrl.ts:54-58` já pedia "a third should extract"; hoje há `sem_nivel` e `sem_partido` e este item adiciona `sem_dobradinha` + `sem_lideranca`.
|
||||
2. **`buildMunicipalityListWhere(state, sets?)`** — assinatura ampliada sem quebrar call sites atuais (map/página passam conjuntos; teste unitário puro continua valendo para o resto). `leadership`→`id: { in: union(named) }` / `id: { not_in: union(all) }`; `party`→`stateDeputies: { in: partyDeputyIDs }` / `stateDeputies: { not_in: allPartyDeputyIDs }`; `stateDeputy` direto. **Guard fail-closed:** se `leaderships` OU `parties` ativos sem `sets`, lança — nenhum caller pode dropar filtro em silêncio.
|
||||
3. **Novo módulo server-only `municipalityListRelationSets.ts`** — `loadMunicipalityListRelationSets(payload, user, state)` computa 4 conjuntos com `user` + `overrideAccess: false` (scope-honoring): `leadershipMunicipalityIDs` (união dos municípios das lideranças selecionadas), `allLeadershipMunicipalityIDs` (união sobre TODAS as lideranças do escopo; para "Sem liderança"), `partyDeputyIDs` (deputados com `party in selecionados`), `allPartyDeputyIDs` (deputados com qualquer partido; para "Sem partido"). Leitura reversa única de `leadership.municipalities`; `stateDeputy.party` é barato.
|
||||
4. **Facets** — `loadMunicipalityListFilterFacets` estende o `select` das rows para `stateDeputies`; novo campo `stateDeputyIDs` (união dos vínculos nas rows omit), `leadershipIDs` (leitura reversa sobre os ids das rows omit, reusa `loadMunicipalityLeadershipSummaries`), `parties` (lookup de partido dos deputados presentes nas rows). A regra "cada facet omita a dimensão que ela própria possui" exige que `facetRows` passe a resolver `where` por dimensão via `buildMunicipalityListWhere(state+omit, setsDe(state+omit))` — o memo atual por `JSON.stringify(where)` passa a ser por estado omitido (mesma disciplina, chave nova).
|
||||
5. **Omnibox** — grupos novos `Dobradinha` (label `Nome (PARTIDO)`, keywords deputado/dobradinha), `Liderança` (nome do contato, keywords lider/lideranca), `Partido` (sigla, keywords partido/psd/…); sons "Sem …" como seeds estáticos (precedente de "Sem nível", não `emptyQueryVisible` — surgem ao digitar "sem"). Chips: `stateDeputy:<id>`, `leadership:<id>`, `party:<value>` com maps id→nome fornecidos pela página; apply/remove via `toggleMunicipalityMultiFilterValue` genérico (novos params entram em `MunicipalityMultiFilterParam`).
|
||||
6. **Liberação da coluna ao Assessor** — **só UI + catálogo**: o path de escrita já é staff-wide e scoped no servidor (`setStateDeputyMunicipalitiesBatchRecord`/`createMunicipalityStateDeputyRecord` usam `reloadStaffActor` + `canUpdateMunicipality` + `stateDeputies` `canManageCampaignStaffField`, todos staff/portfolio-ready em `actions/stateDeputy.ts`). Mudanças: `MunicipalityList.tsx` (coluna sem o wrapper `isCampaignUnrestricted` no ramo staff; picker idem), `MunicipalityListMobileCards.tsx` (idem), página (`isStaffView ? loadStateDeputyOptions : []`).
|
||||
- **Catálogo do picker da coluna para o assessor — decisão aberta para o gate humano:** registro de dobradinha é staff-wide por design (`canReadStateDeputy` = staff, diferente de `canReadLeadership` que é scoped). **Recomendação:** manter o catálogo completo do staff para o combobox da célula (o write já limita o município; o picker completo evita o assessor não conseguir adicionar uma dobradinha estadual ao seu município), e o **filtro** nasce scoped por vir das rows do portfólio. **Alternativa:** scoping do catálogo da célula (espelha B155) — mais restritivo, mas gera uma restrição nova que contradiz o registro staff-wide. Ajuste é 1 loader (barato). _(Marcar no gate.)_
|
||||
7. **Mapa** — `loadMunicipalityMapBundle` passa a computar `sets` (o mapa compartilha o estado da URL da lista; sem isso um recorte por liderança/partido não filtraria o mapa).
|
||||
8. **Resumo/visita** — `formatMunicipalityActiveFiltersSummary` ganha Dobradinha/Liderança por contagem ("N dobradinhas") e Partido por siglas (valores já são legíveis), coerente com Assessor por contagem; chip label dos sentinelas ("Sem dobradinha" etc.).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/utilities/municipality/municipalityListUrl.ts`**: estado + parse/serialize + `where` (com `sets?`) + paramNames; sentinelas `NO_STATE_DEPUTY_FILTER_VALUE`/`NO_LEADERSHIP_FILTER_VALUE` (+ helper de sentinela compartilhado reusando `NO_PARTY_FILTER_VALUE`).
|
||||
- **`src/utilities/municipality/municipalityListFilters.ts`**: `MunicipalityMultiFilterParam` + `stateDeputies|leaderships|parties`; toggles/clear já genéricos; summary + `buildMunicipalityFilterOptionHref` (fast-path dos novos params).
|
||||
- **`src/utilities/municipality/municipalityRelationSets.ts`** (novo, server-only): conjuntos pré-calculados + rótulos (nomes para o omnibox/facets) respeitando escopo.
|
||||
- **`src/utilities/municipality/municipalityPageData.ts`**: `MunicipalityListFilterFacets` + conjuntos no bundle; página/mapa consomem.
|
||||
- **`src/utilities/municipality/municipalityOmnibox.ts`**: chips/seeds/apply/remove dos 3 grupos + "Sem …".
|
||||
- **`src/utilities/municipality/municipalityMapData.ts`**: `sets` no `buildMunicipalityListWhere`.
|
||||
- **`src/components/campaign/municipality/MunicipalityFilters.tsx`**: props novas (`stateDeputyFilterOptions`, `leadershipFilterOptions`, `partyFilterOptions`, maps id→nome) → seeds/chips.
|
||||
- **`src/components/campaign/municipality/MunicipalityList.tsx` / `MunicipalityListMobileCards.tsx`**: coluna Dobradinhas liberada ao staff (ramo `isStaffView`).
|
||||
- **`src/app/(campaign)/campanha/(app)/municipios/page.tsx`**: carrega `stateDeputyOptions` para todo staff; monta as option lists dos novos grupos + maps.
|
||||
- **Migration:** sem migration (sem schema).
|
||||
- **Access / Consent:** sem Consent; escopo via `user` + `overrideAccess: false` já existentes; fail-closed no guard de `sets`.
|
||||
- **UI:** Impeccable **B** — encaixe na omnibox existente (shell `CampaignListOmnibox`, chips, grupos) e no gate de coluna; nenhum componente novo de superfície além dos option lists. Shape→craft (nomes/chips legíveis; grupo "Partido" por sigla; sentinelas com rótulo PT).
|
||||
|
||||
### Dados → forma (pergunta 3)
|
||||
|
||||
- Forma escolhida: sem dados novos — quantitativo dos recortes já visível; chips citam o mesmo nome das colunas (`stateDeputyDisplayName` / nome do contato da liderança) — única leitura de nomes alimenta coluna e filtro (precedente B155/B157).
|
||||
- Rejeitadas: % estadual, expor `estimatedVotes`, contagem de seleção além da própria lista.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **URL + where + sets (schema/server)** — sentinela compartilhado, `stateDeputies|leaderships|parties` no estado/parse/serialize, `buildMunicipalityListWhere(state, sets?)` fail-closed, `loadMunicipalityListRelationSets`, facets estendidas, `mapData` com sets. Testes unitários (parse/serialize/where/sentinela/omit-por-dimensão) + int (conjuntos + facets no `teqo_wt…_test`).
|
||||
2. **UI** — `MunicipalityFilters` + omnibox (grupos, seeds, chips, apply/remove, summary/visita), liberação da coluna (desktop + mobile + picker), página (catalog staff-wide + options). Impeccable B no encaixe.
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; e2e: inverter o teste "keeps Dobradinha restricted…" → assessor vê/edita a coluna; assessor edita inline dentro do portfólio e é rejeitado fora; adicionar filtros por Dobradinha/Liderança/Partido + "Sem …" (chip, URL, lista). Entregar com `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **`not_in` com lista vazia** (ex.: "Sem liderança" com zero lideranças no escopo) — semântica SQL a fixar **com teste** (special-case: `in: []` matchea nada; `not_in: []` matchea tudo).
|
||||
- **Spillover para a página de edição da ficha (`/editar`)** — a intenção cobre só a **coluna** da lista; a ficha fica como está (fora de escopo; notar como follow-up se o gate quiser).
|
||||
- **Popover de filtro no header das colunas** — decidido "não" no gate; sem trabalho.
|
||||
- **"Sem organização"/filtros das outras listas** — for a de escopo (candidatos a Items sucessores).
|
||||
- **Otimização além do padrão** — se os conjuntos exigirem N+1, medir antes (regra da intenção); hoje é 1–3 leituras únicas.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Custo das facets/leitura reversa no 435-linha (coordenador):** mitigado com leitura única (sem N+1), `index: true` em `municipality.stateDeputies`/`leadership.municipalities`, e reuso do scope da página como semente (mesmo padrão B16+).
|
||||
- **Regressão no contrato de URL congelado (B18/saved filters):** mudança estritamente aditiva; parse tolerante (IDs inválidos/fora de escopo → simplesmente não matcheiam); bookmarks antigos continuam válidos (params ausentes = sem restrição).
|
||||
- **Assessor editar/vincular fora do portfólio:** já rejeitado no servidor (B37/B157 `canUpdateMunicipality`); teste de acesso no aceite; UI não esconde a recusa.
|
||||
- **Vazamento de escopo nas facets:** todas as reads com `overrideAccess: false`; facet de liderança honra `canReadLeadership`.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (3 dimensões + 3 "Sem…" na omnibox; coluna Dobradinhas staff-wide; URL aditiva; assessor scoped no servidor)
|
||||
- [x] Invariantes AGENTS/engineering-standards (identificadores EN; copy PT; sem migration; sem Consent novo; `overrideAccess: false`)
|
||||
- [x] Testes de domínio: unit (parse/serialize/where/sentinela/omnibox) + int (sets/facets) + e2e (coluna ao assessor desktop+mobile; edição no portfólio; filtros novos)
|
||||
|
||||
## Débitos diferidos (gate capture-review-debts 2026-08-09)
|
||||
|
||||
Achados P3 dos reviewers de /simplify, deferidos com gatilho (não poluem a fila):
|
||||
|
||||
- **Gatilho (se tocar no resumo de filtros da lista):** consolidar os 3 blocos `formatAbsenceAwareSummary` num formatter tipado de dimensão (contagem/partido) — hoje um helper local cobre; só vale extrair quando uma 4ª dimensão aparecer.
|
||||
- **Gatilho (se tocar em `parseAdvisorsParam`/canonicalização):** unificar `parseAdvisorsParam` com o núcleo de `canonicalRelationshipWithAbsenceValues` (mesmo loop sem sentinela) — 7 linhas privadas no mesmo arquivo, adiado.
|
||||
- **Gatilho (se o combobox da coluna Dobradinhas mostrar `Dobradinha #N` para id selecionado fora do facet):** preferir o nome do registro staff-wide antes do placeholder — hoje o cenário não ocorre (facet une selecionados + registro é staff-wide), mantido o precedente `Assessor #N`.
|
||||
|
|
@ -95,9 +95,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
// All staff edit the Lideranças column (scoped server-side); the leader
|
||||
// never reaches this page (`gate: noLeader`).
|
||||
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
|
||||
// B157 — the Dobradinhas column is coordinator + candidate only; the
|
||||
// catalog serves the avatar-stack display, the tooltip and the search.
|
||||
canMoveEngagementLevel ? loadStateDeputyOptions(payload, user) : [],
|
||||
// B157 + B176 — the Dobradinhas column is now staff-wide (was
|
||||
// coordinator + candidate only); the catalog serves the avatar-stack
|
||||
// display, the tooltip, the search AND the B176 filter options.
|
||||
isStaffView ? loadStateDeputyOptions(payload, user) : [],
|
||||
])
|
||||
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
|
||||
|
||||
|
|
@ -117,19 +118,43 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')),
|
||||
}
|
||||
|
||||
// B176 — filter options for the new omnibox groups, narrowed by the other
|
||||
// active filters and labeled from the same catalogs the columns use.
|
||||
const stateDeputyByName = new Map(stateDeputyOptions.map((option) => [option.id, option]))
|
||||
const selectedStateDeputyIDs = new Set(
|
||||
(state.stateDeputies ?? []).filter((value): value is number => typeof value === 'number'),
|
||||
)
|
||||
const globalFilterOptions = {
|
||||
stateDeputy: filterFacets.stateDeputyIDs
|
||||
.flatMap((id) => {
|
||||
const option = stateDeputyByName.get(id)
|
||||
if (option) return [{ value: String(id), label: option.name }]
|
||||
return selectedStateDeputyIDs.has(id)
|
||||
? [{ value: String(id), label: `Dobradinha #${id}` }]
|
||||
: []
|
||||
})
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')),
|
||||
leadership: filterFacets.leadershipOptions
|
||||
.map((leadership) => ({ value: String(leadership.id), label: leadership.name }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')),
|
||||
party: filterFacets.parties.map((party) => ({ value: party, label: party })),
|
||||
}
|
||||
|
||||
const filters = (
|
||||
<MunicipalityFilters
|
||||
state={state}
|
||||
showStaffFilters={isStaffView}
|
||||
regionFilterOptions={columnFilterOptions.region}
|
||||
advisorFilterOptions={columnFilterOptions.advisor}
|
||||
stateDeputyFilterOptions={globalFilterOptions.stateDeputy}
|
||||
leadershipFilterOptions={globalFilterOptions.leadership}
|
||||
partyFilterOptions={globalFilterOptions.party}
|
||||
slugFilterValues={columnFilterOptions.name}
|
||||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={municipalityListPickerColumns({
|
||||
isStaffView,
|
||||
isCampaignUnrestricted: canMoveEngagementLevel,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,17 +30,36 @@ type MunicipalityFiltersProps = {
|
|||
regionFilterOptions: MunicipalityFilterOption[]
|
||||
/** Advisor options already narrowed by the other active filters. */
|
||||
advisorFilterOptions: MunicipalityFilterOption[]
|
||||
/** B176 — Dobradinha, Liderança and Partido options (named, narrowed). */
|
||||
stateDeputyFilterOptions: MunicipalityFilterOption[]
|
||||
leadershipFilterOptions: MunicipalityFilterOption[]
|
||||
partyFilterOptions: MunicipalityFilterOption[]
|
||||
/** Facet slugs (labeled from the catalog on the client). */
|
||||
slugFilterValues?: readonly string[]
|
||||
/** Beside the omnibox (B137): column picker, save bookmark, … */
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
/** Chip-label lookup for a numeric-id filter dimension (advisor/dobradinha/liderança). */
|
||||
const labelsByIdFromOptions = (
|
||||
options: readonly MunicipalityFilterOption[],
|
||||
): Map<number, string> => {
|
||||
const map = new Map<number, string>()
|
||||
for (const option of options) {
|
||||
const id = Number(option.value)
|
||||
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const MunicipalityFilters = ({
|
||||
state,
|
||||
showStaffFilters,
|
||||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
stateDeputyFilterOptions = [],
|
||||
leadershipFilterOptions = [],
|
||||
partyFilterOptions = [],
|
||||
slugFilterValues = [],
|
||||
trailing,
|
||||
}: MunicipalityFiltersProps) => {
|
||||
|
|
@ -58,14 +77,20 @@ export const MunicipalityFilters = ({
|
|||
[slugFilterValues],
|
||||
)
|
||||
|
||||
const advisorLabelsById = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const option of advisorFilterOptions) {
|
||||
const id = Number(option.value)
|
||||
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
|
||||
}
|
||||
return map
|
||||
}, [advisorFilterOptions])
|
||||
const advisorLabelsById = useMemo(
|
||||
() => labelsByIdFromOptions(advisorFilterOptions),
|
||||
[advisorFilterOptions],
|
||||
)
|
||||
|
||||
const stateDeputyLabelsById = useMemo(
|
||||
() => labelsByIdFromOptions(stateDeputyFilterOptions),
|
||||
[stateDeputyFilterOptions],
|
||||
)
|
||||
|
||||
const leadershipLabelsById = useMemo(
|
||||
() => labelsByIdFromOptions(leadershipFilterOptions),
|
||||
[leadershipFilterOptions],
|
||||
)
|
||||
|
||||
const chips = useMemo(
|
||||
() =>
|
||||
|
|
@ -74,8 +99,17 @@ export const MunicipalityFilters = ({
|
|||
scenario,
|
||||
showStaffFilters,
|
||||
advisorLabelsById,
|
||||
stateDeputyLabelsById,
|
||||
leadershipLabelsById,
|
||||
}),
|
||||
[state, scenario, showStaffFilters, advisorLabelsById],
|
||||
[
|
||||
state,
|
||||
scenario,
|
||||
showStaffFilters,
|
||||
advisorLabelsById,
|
||||
stateDeputyLabelsById,
|
||||
leadershipLabelsById,
|
||||
],
|
||||
)
|
||||
|
||||
const suggestionSeeds = useMemo(
|
||||
|
|
@ -85,8 +119,19 @@ export const MunicipalityFilters = ({
|
|||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
slugFilterOptions,
|
||||
stateDeputyFilterOptions,
|
||||
leadershipFilterOptions,
|
||||
partyFilterOptions,
|
||||
}),
|
||||
[showStaffFilters, regionFilterOptions, advisorFilterOptions, slugFilterOptions],
|
||||
[
|
||||
showStaffFilters,
|
||||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
slugFilterOptions,
|
||||
stateDeputyFilterOptions,
|
||||
leadershipFilterOptions,
|
||||
partyFilterOptions,
|
||||
],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(
|
||||
|
|
|
|||
|
|
@ -484,37 +484,36 @@ const municipalityListColumns = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
...(isCampaignUnrestricted
|
||||
? ([
|
||||
{
|
||||
id: 'stateDeputies',
|
||||
label: municipalityColumnLabels.stateDeputies,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={municipalityColumnDescriptions.stateDeputies}
|
||||
className={responsiveColumnClassName.stateDeputies}
|
||||
>
|
||||
{municipalityColumnLabels.stateDeputies}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: cn(
|
||||
responsiveColumnClassName.stateDeputies,
|
||||
'max-w-56 whitespace-normal',
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
{
|
||||
// B176 — staff-wide since 2026-08-09: the edit is scoped to the
|
||||
// actor's administered municípios server-side (B37/B157), so the
|
||||
// advisor gets the same inline control the Lideranças column has.
|
||||
id: 'stateDeputies',
|
||||
label: municipalityColumnLabels.stateDeputies,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={municipalityColumnDescriptions.stateDeputies}
|
||||
className={responsiveColumnClassName.stateDeputies}
|
||||
>
|
||||
{municipalityColumnLabels.stateDeputies}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: cn(
|
||||
responsiveColumnClassName.stateDeputies,
|
||||
'max-w-56 whitespace-normal',
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'goalCoverage',
|
||||
|
|
@ -592,11 +591,7 @@ const municipalityListColumns = ({
|
|||
|
||||
export const municipalityListPickerColumns = ({
|
||||
isStaffView,
|
||||
isCampaignUnrestricted,
|
||||
}: Pick<
|
||||
MunicipalityListProps,
|
||||
'isStaffView' | 'isCampaignUnrestricted'
|
||||
>): CampaignColumnPickerColumn[] => {
|
||||
}: Pick<MunicipalityListProps, 'isStaffView'>): CampaignColumnPickerColumn[] => {
|
||||
const base: CampaignColumnPickerColumn[] = [
|
||||
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
|
||||
{ id: 'votos', label: municipalityColumnLabels.votos },
|
||||
|
|
@ -611,9 +606,7 @@ export const municipalityListPickerColumns = ({
|
|||
{ id: 'advisors', label: municipalityColumnLabels.advisors },
|
||||
{ id: 'trend', label: municipalityColumnLabels.trend },
|
||||
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
|
||||
...(isCampaignUnrestricted
|
||||
? [{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies }]
|
||||
: []),
|
||||
{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies },
|
||||
{ id: 'goalCoverage', label: municipalityColumnLabels.goalCoverage },
|
||||
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -234,7 +234,9 @@ export const MunicipalityListMobileCards = ({
|
|||
/>
|
||||
</dd>
|
||||
</div>
|
||||
{isCampaignUnrestricted ? (
|
||||
{/* B176 — staff-wide since 2026-08-09; the write stays scoped to
|
||||
the actor's administered municípios (B37/B157). */}
|
||||
{isStaffView ? (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Dobradinhas</dt>
|
||||
<dd>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,41 @@
|
|||
import type { Where } from 'payload'
|
||||
|
||||
export type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
/**
|
||||
* 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). Lives here so
|
||||
* every list that filters municipalities by a dobradinha's party — the
|
||||
* dobradinhas list and the municípios list — shares ONE sentinel value.
|
||||
*/
|
||||
export const NO_PARTY_FILTER_VALUE = 'sem_partido'
|
||||
|
||||
/**
|
||||
* Splits a multi-value filter into its named values and whether the "absence"
|
||||
* sentinel is selected. Every list filter with a selectable absence (level's
|
||||
* "Sem nível", party's "Sem partido", B176's "Sem dobradinha" / "Sem
|
||||
* liderança") repeats this pair — extract here instead of a per-list copy.
|
||||
*/
|
||||
export const splitAbsenceFilterValues = <Named>(
|
||||
values: readonly Named[],
|
||||
isAbsence: (value: Named) => boolean,
|
||||
): { named: Named[]; hasAbsence: boolean } => {
|
||||
const named: Named[] = []
|
||||
let hasAbsence = false
|
||||
for (const value of values) {
|
||||
if (isAbsence(value)) hasAbsence = true
|
||||
else named.push(value)
|
||||
}
|
||||
return { named, hasAbsence }
|
||||
}
|
||||
|
||||
/** Collapses 0..n OR branches: none → undefined, one → the branch, many → `{ or }`. */
|
||||
export const collapseListWhereOrBranches = (branches: readonly Where[]): Where | undefined =>
|
||||
branches.length > 1 ? { or: [...branches] } : branches[0]
|
||||
|
||||
export const firstValue = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { EMPTY_ENGAGEMENT_LEVEL_LABEL, formatEngagementLevelLabel } from '@/lib/engagementLevel'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import { NO_PARTY_FILTER_VALUE, truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
municipalityListCoverageLabels,
|
||||
municipalityPriorityLabels,
|
||||
|
|
@ -16,9 +16,13 @@ import {
|
|||
} from '@/utilities/municipality/municipalityLabels'
|
||||
import {
|
||||
buildMunicipalityListHref,
|
||||
canonicalPartyValues,
|
||||
canonicalRelationshipWithAbsenceValues,
|
||||
municipalityListLevelFilterValues,
|
||||
municipalityListStateToRawParams,
|
||||
NO_LEADERSHIP_FILTER_VALUE,
|
||||
NO_LEVEL_FILTER_VALUE,
|
||||
NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
parseMunicipalityListParams,
|
||||
serializeCanonicalMunicipalityListSearchParams,
|
||||
type MunicipalityListLevelFilterValue,
|
||||
|
|
@ -151,6 +155,9 @@ export type MunicipalityMultiFilterParam =
|
|||
| 'trend'
|
||||
| 'class'
|
||||
| 'level'
|
||||
| 'stateDeputy'
|
||||
| 'leadership'
|
||||
| 'party'
|
||||
|
||||
export const getMunicipalityMultiFilterValues = (
|
||||
state: MunicipalityListState,
|
||||
|
|
@ -161,6 +168,9 @@ export const getMunicipalityMultiFilterValues = (
|
|||
if (param === 'trend') return state.trends ?? []
|
||||
if (param === 'class') return state.classes ?? []
|
||||
if (param === 'level') return state.levels ?? []
|
||||
if (param === 'stateDeputy') return (state.stateDeputies ?? []).map(String)
|
||||
if (param === 'leadership') return (state.leaderships ?? []).map(String)
|
||||
if (param === 'party') return state.parties ?? []
|
||||
return (state.advisors ?? []).map(String)
|
||||
}
|
||||
|
||||
|
|
@ -299,6 +309,21 @@ export const buildMunicipalityFilterOptionHref = (
|
|||
const levels = toggled.filter(isEngagementLevelFilterValue)
|
||||
if (levels.length && levels.length < engagementLevelFilterCount) next.levels = levels
|
||||
else delete next.levels
|
||||
} else if (param === 'stateDeputy') {
|
||||
const stateDeputies = canonicalRelationshipWithAbsenceValues(
|
||||
toggled,
|
||||
NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
)
|
||||
if (stateDeputies.length) next.stateDeputies = stateDeputies
|
||||
else delete next.stateDeputies
|
||||
} else if (param === 'leadership') {
|
||||
const leaderships = canonicalRelationshipWithAbsenceValues(toggled, NO_LEADERSHIP_FILTER_VALUE)
|
||||
if (leaderships.length) next.leaderships = leaderships
|
||||
else delete next.leaderships
|
||||
} else if (param === 'party') {
|
||||
const parties = canonicalPartyValues(toggled)
|
||||
if (parties.length) next.parties = parties
|
||||
else delete next.parties
|
||||
}
|
||||
|
||||
const query = serializeCanonicalMunicipalityListSearchParams(next).toString()
|
||||
|
|
@ -366,11 +391,57 @@ export const formatMunicipalityActiveFiltersSummary = (
|
|||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (state.stateDeputies?.length) {
|
||||
const count = state.stateDeputies.filter((value) => typeof value === 'number').length
|
||||
const phrase = count === 1 ? '1 dobradinha' : `${count} dobradinhas`
|
||||
parts.push(
|
||||
formatAbsenceAwareSummary(
|
||||
count ? phrase : 'Sem dobradinha',
|
||||
state.stateDeputies.includes(NO_STATE_DEPUTY_FILTER_VALUE),
|
||||
'Sem dobradinha',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.leaderships?.length) {
|
||||
const count = state.leaderships.filter((value) => typeof value === 'number').length
|
||||
const phrase = count === 1 ? '1 liderança' : `${count} lideranças`
|
||||
parts.push(
|
||||
formatAbsenceAwareSummary(
|
||||
count ? phrase : 'Sem liderança',
|
||||
state.leaderships.includes(NO_LEADERSHIP_FILTER_VALUE),
|
||||
'Sem liderança',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.parties?.length) {
|
||||
const named = state.parties.filter((party) => party !== NO_PARTY_FILTER_VALUE)
|
||||
parts.push(
|
||||
formatAbsenceAwareSummary(
|
||||
named.length ? `Partido ${named.join(', ')}` : 'Sem partido',
|
||||
state.parties.includes(NO_PARTY_FILTER_VALUE),
|
||||
'Sem partido',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
}
|
||||
|
||||
/**
|
||||
* B176 — "N dobradinhas" / "Partido PT, PSD" with the selectable absence: when
|
||||
* the sentinel is ALSO selected it rides along as "… + sem X"; an absence-only
|
||||
* selection reads as the bare absence label.
|
||||
*/
|
||||
const formatAbsenceAwareSummary = (
|
||||
basePhrase: string,
|
||||
hasAbsence: boolean,
|
||||
absenceLabel: string,
|
||||
): string => {
|
||||
if (!hasAbsence) return basePhrase
|
||||
return basePhrase === absenceLabel ? basePhrase : `${basePhrase} + ${absenceLabel.toLowerCase()}`
|
||||
}
|
||||
|
||||
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
|
||||
|
||||
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
|
|||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
collapseListWhereOrBranches,
|
||||
createSortToggleHref,
|
||||
firstValue,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
normalizedText,
|
||||
parseExhaustiveEnumParam,
|
||||
resolveListUrl,
|
||||
splitAbsenceFilterValues,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams as CampaignListRawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
|
|
@ -65,6 +68,26 @@ export const municipalityListLevelFilterValues: readonly MunicipalityListLevelFi
|
|||
NO_LEVEL_FILTER_VALUE,
|
||||
]
|
||||
|
||||
/**
|
||||
* Sentinel for the "Sem dobradinha" filter row: the municipality↔dobradinha
|
||||
* link is a hasMany relationship (`municipality.stateDeputies`), so absence is
|
||||
* `stateDeputies: { exists: false }` — the same pair (reserved token + absence
|
||||
* branch) as `NO_LEVEL_FILTER_VALUE` and `NO_PARTY_FILTER_VALUE`.
|
||||
*/
|
||||
export const NO_STATE_DEPUTY_FILTER_VALUE = 'sem_dobradinha'
|
||||
|
||||
/**
|
||||
* Sentinel for the "Sem liderança" filter row. The link is reverse
|
||||
* (`leadership.municipalities`), so absence is expressed as "municipality not
|
||||
* linked to ANY leadership in the actor's scope" — see
|
||||
* `MunicipalityListRelationCatalog.allLeadershipMunicipalityIDs`.
|
||||
*/
|
||||
export const NO_LEADERSHIP_FILTER_VALUE = 'sem_lideranca'
|
||||
|
||||
type MunicipalityListStateDeputyFilterValue = number | typeof NO_STATE_DEPUTY_FILTER_VALUE
|
||||
|
||||
type MunicipalityListLeadershipFilterValue = number | typeof NO_LEADERSHIP_FILTER_VALUE
|
||||
|
||||
/**
|
||||
* E9 allocation queue: the list opens on the decision it exists to serve —
|
||||
* where the goal is least covered by auditable commitments (biggest deficit
|
||||
|
|
@ -145,12 +168,47 @@ export type MunicipalityListState = {
|
|||
* Never holds the full set (same "todos" canonicalization as `trends`).
|
||||
*/
|
||||
levels?: MunicipalityListLevelFilterValue[]
|
||||
/**
|
||||
* Multi-select (OR) B176 — stateDeputy ids linked to the município, plus the
|
||||
* "Sem dobradinha" sentinel. Direct relationship (`municipality.stateDeputies`),
|
||||
* so unlike `leaderships`/`parties` it needs no precomputed catalog.
|
||||
*/
|
||||
stateDeputies?: MunicipalityListStateDeputyFilterValue[]
|
||||
/**
|
||||
* Multi-select (OR) B176 — leadership ids acting in the município, plus the
|
||||
* "Sem liderança" sentinel. Reverse relationship (`leadership.municipalities`),
|
||||
* so `buildMunicipalityListWhere` derives the municipality-id set from the
|
||||
* request-scoped `MunicipalityListRelationCatalog`.
|
||||
*/
|
||||
leaderships?: MunicipalityListLeadershipFilterValue[]
|
||||
/**
|
||||
* Multi-select (OR) B176 — party names of the município's dobradinhas, plus
|
||||
* `NO_PARTY_FILTER_VALUE`. Needs the party→deputy-id lookup in the catalog.
|
||||
*/
|
||||
parties?: string[]
|
||||
/** Candidate number for the map comparison mode (does not filter the list). */
|
||||
compare?: number
|
||||
sort?: MunicipalityListSortKey
|
||||
dir?: MunicipalityListSortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* B176 — the request-scoped relation sets `buildMunicipalityListWhere` needs to
|
||||
* turn the reverse (`leadership.municipalities`) and cross (`stateDeputy.party`)
|
||||
* filters into municipality-level `where` clauses. State-independent, so a page
|
||||
* or the map loads it ONCE and the pure where-builder derives per-state branches.
|
||||
*/
|
||||
export type MunicipalityListRelationCatalog = {
|
||||
/** Scoped leadership id → municipality ids it acts in (the actor's read scope). */
|
||||
leadershipMunicipalityIDsByLeadership: ReadonlyMap<number, readonly number[]>
|
||||
/** Municipality ids linked to ANY scoped leadership — for "Sem liderança". */
|
||||
allLeadershipMunicipalityIDs: ReadonlySet<number>
|
||||
/** Party name → stateDeputy ids carrying it — for named party filters. */
|
||||
stateDeputyIDsByParty: ReadonlyMap<string, readonly number[]>
|
||||
/** StateDeputy ids that carry any party — for "Sem partido". */
|
||||
allPartyStateDeputyIDs: ReadonlySet<number>
|
||||
}
|
||||
|
||||
export type MunicipalityListSearchParams = CampaignListRawSearchParams
|
||||
|
||||
const municipalityListParamNames = [
|
||||
|
|
@ -163,6 +221,9 @@ const municipalityListParamNames = [
|
|||
'trend',
|
||||
'class',
|
||||
'level',
|
||||
'stateDeputy',
|
||||
'leadership',
|
||||
'party',
|
||||
'compare',
|
||||
'sort',
|
||||
'dir',
|
||||
|
|
@ -265,6 +326,67 @@ const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
|
|||
return advisors.sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
/**
|
||||
* B176 — a relationship multi-select whose absence is a reserved sentinel token
|
||||
* (`sem_dobradinha` / `sem_lideranca`): integer ids sort ascending and the
|
||||
* sentinel rides last, so one value set canonicalizes to one URL. Also the
|
||||
* canonicalizer the fast-path toggle (`buildMunicipalityFilterOptionHref`) uses.
|
||||
*/
|
||||
export const canonicalRelationshipWithAbsenceValues = <Sentinel extends string>(
|
||||
raw: string | string[] | undefined,
|
||||
sentinel: Sentinel,
|
||||
): Array<number | Sentinel> => {
|
||||
const ids: number[] = []
|
||||
const seen = new Set<number>()
|
||||
let hasAbsence = false
|
||||
for (const token of allParamValues(raw)) {
|
||||
if (token === sentinel) {
|
||||
hasAbsence = true
|
||||
continue
|
||||
}
|
||||
const id = strictDecimalInteger(token)
|
||||
if (id === undefined || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
ids.push(id)
|
||||
}
|
||||
return [...ids.sort((left, right) => left - right), ...(hasAbsence ? [sentinel] : [])]
|
||||
}
|
||||
|
||||
const parseStateDeputiesParam = (
|
||||
raw: string | string[] | undefined,
|
||||
): MunicipalityListStateDeputyFilterValue[] =>
|
||||
canonicalRelationshipWithAbsenceValues(raw, NO_STATE_DEPUTY_FILTER_VALUE)
|
||||
|
||||
const parseLeadershipsParam = (
|
||||
raw: string | string[] | undefined,
|
||||
): MunicipalityListLeadershipFilterValue[] =>
|
||||
canonicalRelationshipWithAbsenceValues(raw, NO_LEADERSHIP_FILTER_VALUE)
|
||||
|
||||
/**
|
||||
* B176 — party values canonicalize the same way the relationship dims do:
|
||||
* named parties keep first-seen order (deduped, ≤32 chars) and the "Sem
|
||||
* partido" sentinel rides last, so one value set maps to one URL. Shared by
|
||||
* the URL parser and the fast-path toggle so both writers agree.
|
||||
*/
|
||||
export const canonicalPartyValues = (values: readonly string[]): string[] => {
|
||||
const named: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let hasAbsence = false
|
||||
for (const value of values) {
|
||||
if (value === NO_PARTY_FILTER_VALUE) {
|
||||
hasAbsence = true
|
||||
continue
|
||||
}
|
||||
if (seen.has(value) || value.length > 32) continue
|
||||
seen.add(value)
|
||||
named.push(value)
|
||||
}
|
||||
return [...named, ...(hasAbsence ? [NO_PARTY_FILTER_VALUE] : [])]
|
||||
}
|
||||
|
||||
const parsePartiesParam = (raw: string | string[] | undefined): string[] =>
|
||||
canonicalPartyValues(allParamValues(raw))
|
||||
|
||||
const politicalTrendStatusSet = new Set<string>(Object.keys(politicalTrendLabels))
|
||||
const territorialClassSet = new Set<string>(Object.keys(territorialClassLabels))
|
||||
const engagementLevelFilterSet = new Set<string>(municipalityListLevelFilterValues)
|
||||
|
|
@ -283,6 +405,9 @@ export const municipalityListStateToRawParams = (
|
|||
trend: state.trends,
|
||||
class: state.classes,
|
||||
level: state.levels,
|
||||
stateDeputy: state.stateDeputies?.map(String),
|
||||
leadership: state.leaderships?.map(String),
|
||||
party: state.parties,
|
||||
compare: state.compare === undefined ? undefined : String(state.compare),
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
|
|
@ -310,6 +435,9 @@ export const parseMunicipalityListParams = (
|
|||
params.level,
|
||||
engagementLevelFilterSet,
|
||||
)
|
||||
const stateDeputies = parseStateDeputiesParam(params.stateDeputy)
|
||||
const leaderships = parseLeadershipsParam(params.leadership)
|
||||
const parties = parsePartiesParam(params.party)
|
||||
const rawCompare = strictDecimalInteger(firstValue(params.compare))
|
||||
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
|
||||
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
|
||||
|
|
@ -329,13 +457,19 @@ export const parseMunicipalityListParams = (
|
|||
...(trends.length ? { trends } : {}),
|
||||
...(classes.length ? { classes } : {}),
|
||||
...(levels.length ? { levels } : {}),
|
||||
...(stateDeputies.length ? { stateDeputies } : {}),
|
||||
...(leaderships.length ? { leaderships } : {}),
|
||||
...(parties.length ? { parties } : {}),
|
||||
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
|
||||
...(sort ? { sort } : {}),
|
||||
...(dir ? { dir } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMunicipalityListWhere = (state: MunicipalityListState): Where => {
|
||||
export const buildMunicipalityListWhere = (
|
||||
state: MunicipalityListState,
|
||||
relationCatalog?: MunicipalityListRelationCatalog,
|
||||
): Where => {
|
||||
const filters: Where[] = []
|
||||
const searchedZone = strictDecimalInteger(state.q)
|
||||
|
||||
|
|
@ -359,16 +493,77 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
|
|||
if (state.levels?.length) {
|
||||
// "Sem nível" is absence, which no `in` can express — it rides along as an
|
||||
// OR branch so "N0 ou sem nível" (the triage view) is one query.
|
||||
const selectedLevels = state.levels.filter(
|
||||
(level): level is EngagementLevel => level !== NO_LEVEL_FILTER_VALUE,
|
||||
const { named: selectedLevels, hasAbsence } = splitAbsenceFilterValues(
|
||||
state.levels,
|
||||
(level) => level === NO_LEVEL_FILTER_VALUE,
|
||||
)
|
||||
const levelFilters: Where[] = []
|
||||
if (selectedLevels.length) levelFilters.push({ engagementLevel: { in: selectedLevels } })
|
||||
if (state.levels.includes(NO_LEVEL_FILTER_VALUE)) {
|
||||
levelFilters.push({ engagementLevel: { exists: false } })
|
||||
if (hasAbsence) levelFilters.push({ engagementLevel: { exists: false } })
|
||||
const branch = collapseListWhereOrBranches(levelFilters)
|
||||
if (branch) filters.push(branch)
|
||||
}
|
||||
|
||||
// --- B176: dobradinha (direct relationship) --------------------------------
|
||||
if (state.stateDeputies?.length) {
|
||||
const { named: selectedStateDeputies, hasAbsence } = splitAbsenceFilterValues(
|
||||
state.stateDeputies,
|
||||
(value) => value === NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
)
|
||||
const stateDeputyFilters: Where[] = []
|
||||
if (selectedStateDeputies.length) {
|
||||
stateDeputyFilters.push({ stateDeputies: { in: selectedStateDeputies } })
|
||||
}
|
||||
const [onlyFilter, ...restFilters] = levelFilters
|
||||
filters.push(onlyFilter && restFilters.length === 0 ? onlyFilter : { or: levelFilters })
|
||||
if (hasAbsence) stateDeputyFilters.push({ stateDeputies: { exists: false } })
|
||||
const branch = collapseListWhereOrBranches(stateDeputyFilters)
|
||||
if (branch) filters.push(branch)
|
||||
}
|
||||
|
||||
// --- B176: leadership (reverse relation — needs the catalog) --------------
|
||||
if (state.leaderships?.length) {
|
||||
assertMunicipalityRelationCatalog(state.leaderships, relationCatalog)
|
||||
const { named: selectedLeaderships, hasAbsence } = splitAbsenceFilterValues(
|
||||
state.leaderships,
|
||||
(value) => value === NO_LEADERSHIP_FILTER_VALUE,
|
||||
)
|
||||
const leadershipFilters: Where[] = []
|
||||
if (selectedLeaderships.length) {
|
||||
leadershipFilters.push({
|
||||
id: {
|
||||
in: unionSortedIDsFrom(
|
||||
relationCatalog.leadershipMunicipalityIDsByLeadership,
|
||||
selectedLeaderships.filter((id): id is number => typeof id === 'number'),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (hasAbsence) {
|
||||
leadershipFilters.push({ id: { not_in: [...relationCatalog.allLeadershipMunicipalityIDs] } })
|
||||
}
|
||||
const branch = collapseListWhereOrBranches(leadershipFilters)
|
||||
if (branch) filters.push(branch)
|
||||
}
|
||||
|
||||
// --- B176: partido da dobradinha (cross read — needs the catalog) ---------
|
||||
if (state.parties?.length) {
|
||||
assertMunicipalityRelationCatalog(state.parties, relationCatalog)
|
||||
const { named: namedParties, hasAbsence } = splitAbsenceFilterValues(
|
||||
state.parties,
|
||||
(party) => party === NO_PARTY_FILTER_VALUE,
|
||||
)
|
||||
const partyFilters: Where[] = []
|
||||
if (namedParties.length) {
|
||||
partyFilters.push({
|
||||
stateDeputies: {
|
||||
in: unionSortedIDsFrom(relationCatalog.stateDeputyIDsByParty, namedParties),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (hasAbsence) {
|
||||
partyFilters.push({ stateDeputies: { not_in: [...relationCatalog.allPartyStateDeputyIDs] } })
|
||||
}
|
||||
const branch = collapseListWhereOrBranches(partyFilters)
|
||||
if (branch) filters.push(branch)
|
||||
}
|
||||
// `state.classes` is absent on purpose: the class is derived from the TSE
|
||||
// artifact, not stored, so it can't be a Payload constraint. `municipalityPageData`
|
||||
|
|
@ -377,6 +572,29 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
|
|||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed: the reverse (`leadership`) and cross (`party`) filters can only
|
||||
* become a `where` with the request-scoped catalog. Dropping the filter
|
||||
* silently would hand back a WIDER recorte than the URL asked for.
|
||||
*/
|
||||
function assertMunicipalityRelationCatalog(
|
||||
values: readonly unknown[],
|
||||
relationCatalog: MunicipalityListRelationCatalog | undefined,
|
||||
): asserts relationCatalog is MunicipalityListRelationCatalog {
|
||||
if (!relationCatalog) {
|
||||
throw new Error(
|
||||
`buildMunicipalityListWhere: missing relation catalog for the filter ${JSON.stringify(values)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Union of the id lists behind each key, sorted ascending — the OR of a relation dimension. */
|
||||
const unionSortedIDsFrom = <Key>(
|
||||
byKey: ReadonlyMap<Key, readonly number[]>,
|
||||
keys: readonly Key[],
|
||||
): number[] =>
|
||||
[...new Set(keys.flatMap((key) => byKey.get(key) ?? []))].sort((left, right) => left - right)
|
||||
|
||||
/**
|
||||
* Serializes a state that is ALREADY canonical (came out of
|
||||
* `parseMunicipalityListParams`, or was derived from a canonical state by a
|
||||
|
|
@ -404,6 +622,13 @@ export const serializeCanonicalMunicipalityListSearchParams = (
|
|||
params.append('class', territorialClass)
|
||||
}
|
||||
for (const level of canonicalState.levels ?? []) params.append('level', level)
|
||||
for (const stateDeputy of canonicalState.stateDeputies ?? []) {
|
||||
params.append('stateDeputy', String(stateDeputy))
|
||||
}
|
||||
for (const leadership of canonicalState.leaderships ?? []) {
|
||||
params.append('leadership', String(leadership))
|
||||
}
|
||||
for (const party of canonicalState.parties ?? []) params.append('party', party)
|
||||
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
|
||||
// Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair
|
||||
// is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
mapKeyForMunicipality,
|
||||
} from '@/utilities/municipality/municipalityMapNavigation'
|
||||
import { projectedValidVotes } from '@/utilities/municipality/municipalityPotential'
|
||||
import { loadMunicipalityListRelationCatalog } from '@/utilities/municipality/municipalityRelationSets'
|
||||
import {
|
||||
computeMunicipalityTerritorialClass,
|
||||
type MunicipalityTerritorialClass,
|
||||
|
|
@ -254,7 +255,15 @@ export const loadMunicipalityMapBundle = async (
|
|||
if (user.role === 'leader') return null
|
||||
|
||||
const state = parseMunicipalityListParams(searchParams)
|
||||
const scope = await loadMunicipalityScope(payload, user, buildMunicipalityListWhere(state))
|
||||
// B176 — the map shares the list URL, so a leadership/party recorte must
|
||||
// carry its relation catalog into the very same `where` the list uses. Lazy
|
||||
// per active filter, so the dashboard landing page pays no extra reads.
|
||||
const relationCatalog = await loadMunicipalityListRelationCatalog(payload, user, state)
|
||||
const scope = await loadMunicipalityScope(
|
||||
payload,
|
||||
user,
|
||||
buildMunicipalityListWhere(state, relationCatalog),
|
||||
)
|
||||
const municipalities = scopeMunicipalitiesFromDocs(scope.municipalities)
|
||||
if (municipalities.length === 0) return null
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type VoteEstimateScenario,
|
||||
} from '@/lib/voteEstimate'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
municipalityListCoverageLabels,
|
||||
municipalityPriorityLabels,
|
||||
|
|
@ -31,6 +32,8 @@ import {
|
|||
import {
|
||||
isDefaultMunicipalityListSort,
|
||||
municipalityListSortOptions,
|
||||
NO_LEADERSHIP_FILTER_VALUE,
|
||||
NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
|
|
@ -56,6 +59,9 @@ const MULTI_PREFIXES: Array<{ prefix: string; param: MunicipalityMultiFilterPara
|
|||
{ prefix: 'trend:', param: 'trend' },
|
||||
{ prefix: 'class:', param: 'class' },
|
||||
{ prefix: 'level:', param: 'level' },
|
||||
{ prefix: 'stateDeputy:', param: 'stateDeputy' },
|
||||
{ prefix: 'leadership:', param: 'leadership' },
|
||||
{ prefix: 'party:', param: 'party' },
|
||||
]
|
||||
|
||||
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
|
||||
|
|
@ -84,11 +90,15 @@ export const buildMunicipalityOmniboxChips = ({
|
|||
scenario,
|
||||
showStaffFilters,
|
||||
advisorLabelsById,
|
||||
stateDeputyLabelsById,
|
||||
leadershipLabelsById,
|
||||
}: {
|
||||
state: MunicipalityListState
|
||||
scenario: VoteEstimateScenario
|
||||
showStaffFilters: boolean
|
||||
advisorLabelsById: ReadonlyMap<number, string>
|
||||
stateDeputyLabelsById: ReadonlyMap<number, string>
|
||||
leadershipLabelsById: ReadonlyMap<number, string>
|
||||
}): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
|
|
@ -145,6 +155,40 @@ export const buildMunicipalityOmniboxChips = ({
|
|||
})
|
||||
}
|
||||
|
||||
for (const stateDeputy of state.stateDeputies ?? []) {
|
||||
chips.push({
|
||||
id: `stateDeputy:${stateDeputy}`,
|
||||
label:
|
||||
stateDeputy === NO_STATE_DEPUTY_FILTER_VALUE
|
||||
? 'Dobradinha: Sem dobradinha'
|
||||
: chipLabel(
|
||||
'Dobradinha',
|
||||
stateDeputyLabelsById.get(stateDeputy) ?? `Dobradinha #${stateDeputy}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
for (const leadership of state.leaderships ?? []) {
|
||||
chips.push({
|
||||
id: `leadership:${leadership}`,
|
||||
label:
|
||||
leadership === NO_LEADERSHIP_FILTER_VALUE
|
||||
? 'Liderança: Sem liderança'
|
||||
: chipLabel(
|
||||
'Liderança',
|
||||
leadershipLabelsById.get(leadership) ?? `Liderança #${leadership}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
for (const party of state.parties ?? []) {
|
||||
chips.push({
|
||||
id: `party:${party}`,
|
||||
label:
|
||||
party === NO_PARTY_FILTER_VALUE ? 'Partido: Sem partido' : chipLabel('Partido', party),
|
||||
})
|
||||
}
|
||||
|
||||
if (scenario !== DEFAULT_VOTE_ESTIMATE_SCENARIO) {
|
||||
chips.push({
|
||||
id: 'scenario',
|
||||
|
|
@ -173,11 +217,17 @@ export const buildMunicipalityOmniboxSuggestionSeeds = ({
|
|||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
slugFilterOptions,
|
||||
stateDeputyFilterOptions,
|
||||
leadershipFilterOptions,
|
||||
partyFilterOptions,
|
||||
}: {
|
||||
showStaffFilters: boolean
|
||||
regionFilterOptions: readonly MunicipalityFilterOption[]
|
||||
advisorFilterOptions: readonly MunicipalityFilterOption[]
|
||||
slugFilterOptions: readonly MunicipalityFilterOption[]
|
||||
stateDeputyFilterOptions: readonly MunicipalityFilterOption[]
|
||||
leadershipFilterOptions: readonly MunicipalityFilterOption[]
|
||||
partyFilterOptions: readonly MunicipalityFilterOption[]
|
||||
}): SuggestionSeed[] => {
|
||||
const seeds: SuggestionSeed[] = []
|
||||
|
||||
|
|
@ -229,6 +279,68 @@ export const buildMunicipalityOmniboxSuggestionSeeds = ({
|
|||
)
|
||||
}
|
||||
|
||||
for (const option of stateDeputyFilterOptions) {
|
||||
seeds.push(
|
||||
seedOf({
|
||||
id: `stateDeputy:${option.value}`,
|
||||
group: 'Dobradinha',
|
||||
label: option.label,
|
||||
keywords: ['dobradinha', 'deputado', 'deputada'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// "Sem dobradinha" is the triage of this dimension — same static seed as
|
||||
// "Sem nível" (revealed while typing, never `emptyQueryVisible`).
|
||||
seeds.push(
|
||||
seedOf({
|
||||
id: `stateDeputy:${NO_STATE_DEPUTY_FILTER_VALUE}`,
|
||||
group: 'Dobradinha',
|
||||
label: 'Sem dobradinha',
|
||||
keywords: ['sem', 'dobradinha'],
|
||||
}),
|
||||
)
|
||||
|
||||
for (const option of leadershipFilterOptions) {
|
||||
seeds.push(
|
||||
seedOf({
|
||||
id: `leadership:${option.value}`,
|
||||
group: 'Liderança',
|
||||
label: option.label,
|
||||
keywords: ['lideranca', 'lider', 'liderança'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
seeds.push(
|
||||
seedOf({
|
||||
id: `leadership:${NO_LEADERSHIP_FILTER_VALUE}`,
|
||||
group: 'Liderança',
|
||||
label: 'Sem liderança',
|
||||
keywords: ['sem', 'lideranca', 'lider', 'liderança'],
|
||||
}),
|
||||
)
|
||||
|
||||
for (const option of partyFilterOptions) {
|
||||
seeds.push(
|
||||
seedOf({
|
||||
id: `party:${option.value}`,
|
||||
group: 'Partido',
|
||||
label: option.label,
|
||||
keywords: ['partido', 'sigla', option.value],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
seeds.push(
|
||||
seedOf({
|
||||
id: `party:${NO_PARTY_FILTER_VALUE}`,
|
||||
group: 'Partido',
|
||||
label: 'Sem partido',
|
||||
keywords: ['sem', 'partido'],
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [value, label] of Object.entries(municipalityListCoverageLabels)) {
|
||||
seeds.push(
|
||||
seedOf(
|
||||
|
|
@ -339,12 +451,18 @@ export const buildMunicipalityOmniboxSuggestions = ({
|
|||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
slugFilterOptions,
|
||||
stateDeputyFilterOptions,
|
||||
leadershipFilterOptions,
|
||||
partyFilterOptions,
|
||||
}: {
|
||||
query: string
|
||||
showStaffFilters: boolean
|
||||
regionFilterOptions: readonly MunicipalityFilterOption[]
|
||||
advisorFilterOptions: readonly MunicipalityFilterOption[]
|
||||
slugFilterOptions: readonly MunicipalityFilterOption[]
|
||||
stateDeputyFilterOptions: readonly MunicipalityFilterOption[]
|
||||
leadershipFilterOptions: readonly MunicipalityFilterOption[]
|
||||
partyFilterOptions: readonly MunicipalityFilterOption[]
|
||||
}): CampaignListOmniboxSuggestion[] =>
|
||||
filterMunicipalityOmniboxSuggestions(
|
||||
buildMunicipalityOmniboxSuggestionSeeds({
|
||||
|
|
@ -352,6 +470,9 @@ export const buildMunicipalityOmniboxSuggestions = ({
|
|||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
slugFilterOptions,
|
||||
stateDeputyFilterOptions,
|
||||
leadershipFilterOptions,
|
||||
partyFilterOptions,
|
||||
}),
|
||||
query,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { relationshipId } from '@/lib/relationship'
|
|||
import { type VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
|
||||
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
|
||||
import {
|
||||
loadMunicipalityScope,
|
||||
|
|
@ -33,11 +34,13 @@ import {
|
|||
municipalityPageSize,
|
||||
parseMunicipalityListParams,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityListRelationCatalog,
|
||||
type MunicipalityListSearchParams,
|
||||
type MunicipalityListSortDirection,
|
||||
type MunicipalityListSortKey,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
import { loadMunicipalityListRelationCatalog } from '@/utilities/municipality/municipalityRelationSets'
|
||||
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal'
|
||||
import {
|
||||
computeMunicipalityTerritorialClass,
|
||||
|
|
@ -66,6 +69,12 @@ type MunicipalityListFilterFacets = {
|
|||
slugs: string[]
|
||||
regions: string[]
|
||||
advisorIDs: number[]
|
||||
/** B176 — stateDeputy ids still reachable under the other filters. */
|
||||
stateDeputyIDs: number[]
|
||||
/** B176 — leadership id+name pairs still reachable under the other filters. */
|
||||
leadershipOptions: MunicipalityLeadershipSummary[]
|
||||
/** B176 — party names still reachable under the other filters (no sentinel). */
|
||||
parties: string[]
|
||||
}
|
||||
|
||||
export type MunicipalityListPageBundle = {
|
||||
|
|
@ -84,9 +93,15 @@ const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = {
|
|||
slugs: [],
|
||||
regions: [],
|
||||
advisorIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
leadershipOptions: [],
|
||||
parties: [],
|
||||
}
|
||||
|
||||
type MunicipalityFacetRow = Pick<Municipality, 'slug' | 'region' | 'advisors'>
|
||||
type MunicipalityFacetRow = Pick<
|
||||
Municipality,
|
||||
'id' | 'slug' | 'region' | 'advisors' | 'stateDeputies'
|
||||
>
|
||||
|
||||
/**
|
||||
* E10's class filter is the only one that isn't a Payload constraint (the
|
||||
|
|
@ -115,6 +130,7 @@ const loadMunicipalityListFilterFacets = async (
|
|||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: MunicipalityListState,
|
||||
relationCatalog: MunicipalityListRelationCatalog,
|
||||
/** The scope read is a PROMISE so the facets can join the page's main `Promise.all` (B16+). */
|
||||
loadedScope: { where: Where; rows: Promise<MunicipalityFacetRow[]> } | null,
|
||||
): Promise<MunicipalityListFilterFacets> => {
|
||||
|
|
@ -124,12 +140,19 @@ const loadMunicipalityListFilterFacets = async (
|
|||
const applyClassFilter = (rows: MunicipalityFacetRow[]): MunicipalityFacetRow[] =>
|
||||
classMatches ? rows.filter((row) => classMatches(row.slug)) : rows
|
||||
|
||||
// Dobradinha links are stored on the rows; a deputy id → party inverse comes
|
||||
// from the request-scoped catalog, so the Partido facet never re-queries.
|
||||
const partyOfStateDeputyID = new Map<number, string>()
|
||||
for (const [party, stateDeputyIDs] of relationCatalog.stateDeputyIDsByParty) {
|
||||
for (const id of stateDeputyIDs) partyOfStateDeputyID.set(id, party)
|
||||
}
|
||||
|
||||
const rowsByWhere = new Map<string, Promise<MunicipalityFacetRow[]>>()
|
||||
if (loadedScope) {
|
||||
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows.then(applyClassFilter))
|
||||
}
|
||||
const facetRows = (omit: Partial<MunicipalityListState>): Promise<MunicipalityFacetRow[]> => {
|
||||
const where = buildMunicipalityListWhere({ ...state, ...omit })
|
||||
const where = buildMunicipalityListWhere({ ...state, ...omit }, relationCatalog)
|
||||
const key = JSON.stringify(where)
|
||||
const pending = rowsByWhere.get(key)
|
||||
if (pending) return pending
|
||||
|
|
@ -141,7 +164,7 @@ const loadMunicipalityListFilterFacets = async (
|
|||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { slug: true, region: true, advisors: true },
|
||||
select: { id: true, slug: true, region: true, advisors: true, stateDeputies: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
|
@ -150,13 +173,29 @@ const loadMunicipalityListFilterFacets = async (
|
|||
return rows
|
||||
}
|
||||
|
||||
const [slugRows, regionRows, advisorRows] = await Promise.all([
|
||||
facetRows({ slugs: undefined, priority: undefined }),
|
||||
facetRows({ regions: undefined }),
|
||||
facetRows({ advisors: undefined, coverage: undefined }),
|
||||
])
|
||||
// Named selections are unioned in: a selection must stay visible to be undone.
|
||||
const selectedStateDeputyIDs = new Set<number>()
|
||||
for (const value of state.stateDeputies ?? []) {
|
||||
if (typeof value === 'number') selectedStateDeputyIDs.add(value)
|
||||
}
|
||||
const selectedLeadershipIDs = new Set<number>()
|
||||
for (const value of state.leaderships ?? []) {
|
||||
if (typeof value === 'number') selectedLeadershipIDs.add(value)
|
||||
}
|
||||
const selectedParties = new Set(
|
||||
(state.parties ?? []).filter((party) => party !== NO_PARTY_FILTER_VALUE),
|
||||
)
|
||||
|
||||
const [slugRows, regionRows, advisorRows, stateDeputyRows, leadershipRows, partyRows] =
|
||||
await Promise.all([
|
||||
facetRows({ slugs: undefined, priority: undefined }),
|
||||
facetRows({ regions: undefined }),
|
||||
facetRows({ advisors: undefined, coverage: undefined }),
|
||||
facetRows({ stateDeputies: undefined }),
|
||||
facetRows({ leaderships: undefined }),
|
||||
facetRows({ parties: undefined }),
|
||||
])
|
||||
|
||||
// Selected values are unioned in: a selection must stay visible to be undone.
|
||||
const availableSlugs = new Set([...slugRows.map((row) => row.slug), ...(state.slugs ?? [])])
|
||||
const availableRegions = new Set<string>([
|
||||
...regionRows.map((row) => row.region),
|
||||
|
|
@ -170,12 +209,57 @@ const loadMunicipalityListFilterFacets = async (
|
|||
}
|
||||
}
|
||||
|
||||
const availableStateDeputyIDs = new Set<number>(selectedStateDeputyIDs)
|
||||
for (const row of stateDeputyRows) {
|
||||
for (const stateDeputy of row.stateDeputies ?? []) {
|
||||
const id = relationshipId(stateDeputy)
|
||||
if (id !== null) availableStateDeputyIDs.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const availableParties = new Set<string>(selectedParties)
|
||||
for (const row of partyRows) {
|
||||
for (const stateDeputy of row.stateDeputies ?? []) {
|
||||
const id = relationshipId(stateDeputy)
|
||||
if (id === null) continue
|
||||
const party = partyOfStateDeputyID.get(id)
|
||||
if (party) availableParties.add(party)
|
||||
}
|
||||
}
|
||||
|
||||
// Leaderships of the facet's own row set: the reverse read returns the ids
|
||||
// plus the contact names the filter chips need (honours `canReadLeadership`).
|
||||
const facetLeadershipRows = await loadMunicipalityLeadershipSummaries(
|
||||
payload,
|
||||
user,
|
||||
leadershipRows.map((row) => row.id),
|
||||
)
|
||||
const leadershipNamesById = new Map(facetLeadershipRows.summariesById)
|
||||
for (const leadershipID of selectedLeadershipIDs) {
|
||||
const summary = leadershipNamesById.get(leadershipID)
|
||||
if (!summary) {
|
||||
// A selected leadership that the reversed read cannot see (deleted or
|
||||
// scoped out) stays listed so the filter can be undone.
|
||||
leadershipNamesById.set(leadershipID, {
|
||||
id: leadershipID,
|
||||
name: `Liderança #${leadershipID}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const availableLeadershipIDs = new Set<number>(selectedLeadershipIDs)
|
||||
for (const leadershipID of leadershipNamesById.keys()) availableLeadershipIDs.add(leadershipID)
|
||||
|
||||
return {
|
||||
slugs: municipalityCatalog
|
||||
.filter((entry) => availableSlugs.has(entry.slug))
|
||||
.map((entry) => entry.slug),
|
||||
regions: bahiaIdentityTerritories.filter((territory) => availableRegions.has(territory)),
|
||||
advisorIDs: [...availableAdvisorIDs].sort((left, right) => left - right),
|
||||
stateDeputyIDs: [...availableStateDeputyIDs].sort((left, right) => left - right),
|
||||
leadershipOptions: [...availableLeadershipIDs]
|
||||
.sort((left, right) => left - right)
|
||||
.map((id) => leadershipNamesById.get(id)!),
|
||||
parties: [...availableParties].sort((left, right) => left.localeCompare(right, 'pt-BR')),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,7 +378,6 @@ export const loadMunicipalityListPageBundle = async (
|
|||
searchParams: MunicipalityListSearchParams,
|
||||
): Promise<MunicipalityListPageBundle> => {
|
||||
const state = parseMunicipalityListParams(searchParams)
|
||||
const where = buildMunicipalityListWhere(state)
|
||||
const isStaff = isCampaignStaff(user)
|
||||
const { sort: sortKey, dir: sortDir } = resolveMunicipalityListSort(state)
|
||||
|
||||
|
|
@ -309,6 +392,12 @@ export const loadMunicipalityListPageBundle = async (
|
|||
}
|
||||
}
|
||||
|
||||
// B176 — one request-scoped catalog for every where build (page + facets):
|
||||
// the reverse (`leadership`) and cross (`party`) filters read it. Lazy per
|
||||
// active filter, so a plain list visit pays no extra reads.
|
||||
const relationCatalog = await loadMunicipalityListRelationCatalog(payload, user, state)
|
||||
const where = buildMunicipalityListWhere(state, relationCatalog)
|
||||
|
||||
const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
|
||||
const nativeSortField = payloadSortFieldByKey[sortKey]
|
||||
const classMatches = territorialClassFilterPredicate(state)
|
||||
|
|
@ -342,7 +431,13 @@ export const loadMunicipalityListPageBundle = async (
|
|||
// Request-scoped shared load (docs + pledge aggregates in one place).
|
||||
const staffScopePromise = isStaff
|
||||
? isPagedByPayload
|
||||
? loadMunicipalityScope(payload, user, where)
|
||||
? loadMunicipalityScope(payload, user, where, {
|
||||
// B176 — the paged path seeds the facet memo from these scope rows,
|
||||
// and the Dobradinha/Partido facets read `stateDeputies` off them.
|
||||
// Without the widened select those option groups render empty under
|
||||
// a native sort (the cache key separates the widened call — P3-E).
|
||||
extraSelect: { stateDeputies: true },
|
||||
})
|
||||
: listQuery.then((result) =>
|
||||
loadMunicipalityScopeFromDocs(payload, result.docs as Municipality[]),
|
||||
)
|
||||
|
|
@ -363,6 +458,7 @@ export const loadMunicipalityListPageBundle = async (
|
|||
payload,
|
||||
user,
|
||||
state,
|
||||
relationCatalog,
|
||||
isStaff
|
||||
? { where, rows: staffScopePromise.then((scope) => scope?.municipalities ?? []) }
|
||||
: null,
|
||||
|
|
|
|||
97
src/utilities/municipality/municipalityRelationSets.ts
Normal file
97
src/utilities/municipality/municipalityRelationSets.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types'
|
||||
import {
|
||||
type MunicipalityListRelationCatalog,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
|
||||
/**
|
||||
* B176 — the request-scoped relation sets that turn the reverse
|
||||
* (`leadership.municipalities`) and cross (`stateDeputy.party`) filters into
|
||||
* municipality-level `where` clauses. State-independent, so a page loads it
|
||||
* ONCE and every facet variant + the page query + the map derive their own
|
||||
* subset — no N+1.
|
||||
*
|
||||
* Lazy by half with one exception: the leadership half is only read when a
|
||||
* leadership filter is active (the facets source their names from
|
||||
* `loadMunicipalityLeadershipSummaries`, not from here), while the party half
|
||||
* ALWAYS loads — besides the "Sem partido"/named-party `where` clauses it is
|
||||
* the facet's deputy→party inverse (`partyOfStateDeputyID`), which the Partido
|
||||
* option group reads even when no party filter is set. A no-filter visit pays
|
||||
* one small read instead of two.
|
||||
*
|
||||
* Scope: every read threads `user` + `overrideAccess: false`, so an advisor's
|
||||
* catalog only ever reaches the leaderships of the municípios they administer
|
||||
* (`canReadLeadership`); the dobradinha registry is staff-wide by design.
|
||||
*/
|
||||
export const loadMunicipalityListRelationCatalog = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: MunicipalityListState,
|
||||
): Promise<MunicipalityListRelationCatalog> => {
|
||||
const needsLeadership = Boolean(state.leaderships?.length)
|
||||
|
||||
const [leaderships, stateDeputies] = await Promise.all([
|
||||
needsLeadership
|
||||
? payload.find({
|
||||
collection: 'leadership',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { municipalities: true },
|
||||
where: { municipalities: { exists: true } },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
: null,
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { party: true },
|
||||
where: { party: { exists: true } },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
])
|
||||
|
||||
const leadershipMunicipalityIDsByLeadership = new Map<number, number[]>()
|
||||
const allLeadershipMunicipalityIDs = new Set<number>()
|
||||
if (leaderships) {
|
||||
for (const doc of leaderships.docs as Leadership[]) {
|
||||
const leadershipID = relationshipId(doc.id)
|
||||
if (leadershipID === null) continue
|
||||
const municipalityIDs = (doc.municipalities ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
leadershipMunicipalityIDsByLeadership.set(leadershipID, municipalityIDs)
|
||||
for (const id of municipalityIDs) allLeadershipMunicipalityIDs.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const stateDeputyIDsByParty = new Map<string, number[]>()
|
||||
const allPartyStateDeputyIDs = new Set<number>()
|
||||
if (stateDeputies) {
|
||||
for (const doc of stateDeputies.docs as StateDeputy[]) {
|
||||
if (!doc.party) continue
|
||||
const stateDeputyID = relationshipId(doc.id)
|
||||
if (stateDeputyID === null) continue
|
||||
const idsByParty = stateDeputyIDsByParty.get(doc.party) ?? []
|
||||
idsByParty.push(stateDeputyID)
|
||||
stateDeputyIDsByParty.set(doc.party, idsByParty)
|
||||
allPartyStateDeputyIDs.add(stateDeputyID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
leadershipMunicipalityIDsByLeadership,
|
||||
allLeadershipMunicipalityIDs,
|
||||
stateDeputyIDsByParty,
|
||||
allPartyStateDeputyIDs,
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@ import type { Payload } from 'payload'
|
|||
|
||||
import { populatedContactName, relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import type { CampaignUser, StateDeputy } from '@/payload-types'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
|
||||
import { loadCampaignUserSummaries } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
buildStateDeputyListWhere,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
resolveStateDeputyListPayloadSort,
|
||||
resolveStateDeputyListSort,
|
||||
stateDeputyPageSize,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@
|
|||
* 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, truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
buildStateDeputyListHref,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
parseStateDeputyListParams,
|
||||
stateDeputyListStateToRawParams,
|
||||
type StateDeputyListState,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import type { Where } from 'payload'
|
|||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
collapseListWhereOrBranches,
|
||||
createSortToggleHref,
|
||||
firstValue,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
normalizedText,
|
||||
resolveListUrl,
|
||||
splitAbsenceFilterValues,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
|
|
@ -23,15 +26,6 @@ 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
|
||||
|
|
@ -128,14 +122,17 @@ export const buildStateDeputyListWhere = (state: StateDeputyListState): Where =>
|
|||
if (state.q) filters.push({ 'contact.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 { named: namedParties, hasAbsence } = splitAbsenceFilterValues(
|
||||
state.parties,
|
||||
(party) => party === NO_PARTY_FILTER_VALUE,
|
||||
)
|
||||
const partyFilters: Where[] = []
|
||||
if (namedParties.length) partyFilters.push({ party: { in: namedParties } })
|
||||
if (includesNoParty) partyFilters.push({ party: { exists: false } })
|
||||
if (hasAbsence) 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])
|
||||
const branch = collapseListWhereOrBranches(partyFilters)
|
||||
if (branch) filters.push(branch)
|
||||
}
|
||||
|
||||
return filters.length ? { and: filters } : {}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
buildStateDeputyPartyOptions,
|
||||
clearStateDeputyListFilters,
|
||||
|
|
@ -14,7 +15,6 @@ import {
|
|||
type StateDeputyFilterOption,
|
||||
} from '@/utilities/stateDeputyListFilters'
|
||||
import {
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
resolveStateDeputyListSort,
|
||||
stateDeputyListSortOptions,
|
||||
type StateDeputyListSortDirection,
|
||||
|
|
|
|||
|
|
@ -131,7 +131,9 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
|
||||
// B145: assessoria left the detail hero; it still appears on the dossiê tab.
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`)
|
||||
await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible()
|
||||
// `.nth(0)`: same transient hydration duplicate the "Otimista: 5.000"
|
||||
// assertion pins — the server-composed slot briefly renders twice.
|
||||
await expect(page.getByText(`Assessoria: ${advisor.name}`).nth(0)).toBeVisible()
|
||||
})
|
||||
|
||||
test('coordinator assigns an advisor from the list combobox with auto-save (B27)', async ({
|
||||
|
|
@ -357,6 +359,159 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('B176 — filters the list by Dobradinha, Liderança and Partido from the omnibox', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenador Filtros B176'),
|
||||
})
|
||||
const municipalityA = await fixtures.claimMunicipality()
|
||||
const municipalityB = await fixtures.claimMunicipality()
|
||||
|
||||
// A dobradinha linked to municipality A only, with a distinctive party so
|
||||
// the Partido facet cannot collide with other parallel specs.
|
||||
const deputyName = fixtures.value('Deputada B176')
|
||||
const deputyContact = await fixtures.payload.create({
|
||||
collection: 'contact',
|
||||
data: { name: deputyName, state: 'BA' },
|
||||
depth: 0,
|
||||
})
|
||||
const deputy = await fixtures.payload.create({
|
||||
collection: 'stateDeputy',
|
||||
data: { contact: deputyContact.id, party: 'BR176', slug: fixtures.value('deputado') },
|
||||
depth: 0,
|
||||
draft: false,
|
||||
})
|
||||
await fixtures.payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipalityA.id,
|
||||
data: { stateDeputies: [deputy.id] },
|
||||
depth: 0,
|
||||
})
|
||||
fixtures.touchMunicipality(municipalityA.id)
|
||||
|
||||
// A leadership acting on municipality B only.
|
||||
const leadership = await fixtures.createStaffLeadership({
|
||||
namePrefix: 'Liderança B176',
|
||||
municipalities: [municipalityB],
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await ensureWideMunicipalityList(page)
|
||||
|
||||
const omnibox = page.getByRole('combobox', { name: 'Filtrar municípios' })
|
||||
const listbox = page.getByRole('listbox', { name: 'Filtrar municípios' })
|
||||
const tableRows = municipalityContainer(page).locator('tbody tr')
|
||||
// The omnibox chips live in the search form ABOVE the list container.
|
||||
const removeChip = (label: string) => page.getByRole('button', { name: `Remover ${label}` })
|
||||
|
||||
// Dobradinha by contact name → chip + only municipality A remains.
|
||||
await omnibox.fill(deputyName)
|
||||
await listbox.getByRole('option', { name: new RegExp(`^${deputyName} \\(BR176\\)$`) }).click()
|
||||
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toBeVisible()
|
||||
await expect(tableRows).toHaveCount(1)
|
||||
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
|
||||
|
||||
// Removing the chip restores the unfiltered 25-row page 1.
|
||||
await removeChip(`Dobradinha: ${deputyName} (BR176)`).click()
|
||||
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toHaveCount(0)
|
||||
|
||||
// Partido by acronym → the same single municipality (its only dobradinha).
|
||||
await omnibox.fill('BR176')
|
||||
await listbox.getByRole('option', { name: 'BR176', exact: true }).click()
|
||||
await expect(removeChip('Partido: BR176')).toBeVisible()
|
||||
await expect(tableRows).toHaveCount(1)
|
||||
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
|
||||
|
||||
await removeChip('Partido: BR176').click()
|
||||
await expect(removeChip('Partido: BR176')).toHaveCount(0)
|
||||
|
||||
// Liderança by contact name → municipality B only.
|
||||
await omnibox.fill(leadership.contactName)
|
||||
await listbox.getByRole('option', { name: new RegExp(`^${leadership.contactName}$`) }).click()
|
||||
await expect(removeChip(`Liderança: ${leadership.contactName}`)).toBeVisible()
|
||||
await expect(tableRows).toHaveCount(1)
|
||||
await expect(page.getByRole('link', { name: municipalityB.name, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('B176 — advisor edits Dobradinhas inline within an administered municipality', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value('Assessor B176'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
await fixtures.payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
data: { advisors: [advisor.id] },
|
||||
depth: 0,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
// A dobradinha linked to NO municipality — the full staff catalog must
|
||||
// still offer it to the advisor in the cell (gate 2026-08-09).
|
||||
const deputyName = fixtures.value('Deputado Disponível B176')
|
||||
const contact = await fixtures.payload.create({
|
||||
collection: 'contact',
|
||||
data: { name: deputyName, state: 'BA', city: null },
|
||||
depth: 0,
|
||||
})
|
||||
await fixtures.payload.create({
|
||||
collection: 'stateDeputy',
|
||||
data: { contact: contact.id, party: 'PSD', slug: fixtures.value('deputado') },
|
||||
depth: 0,
|
||||
draft: false,
|
||||
})
|
||||
|
||||
await campaign.login(page, advisor.email!, advisor.password)
|
||||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await ensureWideMunicipalityList(page)
|
||||
|
||||
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
|
||||
const popover = page.locator('[data-slot="popover-content"]')
|
||||
await expect(popover).toBeVisible()
|
||||
const search = popover.getByRole('combobox', { name: 'Buscar dobradinha' })
|
||||
const chip = popover.getByRole('button', { name: `Remover ${deputyName} (PSD)` })
|
||||
|
||||
await expect(async () => {
|
||||
await search.fill(deputyName)
|
||||
// Register the write-waiter before the click: asserting the optimistic
|
||||
// chip alone proves nothing about the database, and a reload racing the
|
||||
// POST would capture the pre-write state (the B32+ auto-save race).
|
||||
const saved = expectPostResponse(page, '/campanha/municipios')
|
||||
await popover.getByRole('option', { name: new RegExp(`^${deputyName} \\(PSD\\)$`) }).click()
|
||||
await expect(chip).toBeVisible({ timeout: 5_000 })
|
||||
await saved
|
||||
}).toPass({ timeout: 25_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Persistence across reload: the server holds the assignment.
|
||||
await page.reload()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await ensureWideMunicipalityList(page)
|
||||
await expect(async () => {
|
||||
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
|
||||
await expect(
|
||||
page.locator('[data-slot="popover-content"]').getByRole('button', {
|
||||
name: `Remover ${deputyName} (PSD)`,
|
||||
}),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
}).toPass({ timeout: 25_000 })
|
||||
})
|
||||
|
||||
test('coordinator sets trend status and justification with auto-save (B24)', async ({
|
||||
campaign,
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
|
|||
await expectNoHorizontalOverflow(container)
|
||||
})
|
||||
|
||||
test('keeps Dobradinha restricted even when an advisor has enough room', async ({
|
||||
test('releases the Dobradinhas column to the advisor at full width (B176)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -213,7 +213,9 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
|
|||
await setContainerWidth(container, 84 * REM_IN_PIXELS + 1)
|
||||
|
||||
await expect(container.getByRole('columnheader', { name: /Assessores/ })).toBeVisible()
|
||||
await expect(container.getByRole('columnheader', { name: 'Dobradinha' })).toHaveCount(0)
|
||||
// B176 (2026-08-09): the Dobradinhas column is staff-wide — the advisor
|
||||
// sees it once the container has room, same as the coordinator/candidate.
|
||||
await expect(container.getByRole('columnheader', { name: 'Dobradinha' })).toBeVisible()
|
||||
await expectNoHorizontalOverflow(container)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,10 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
|
|||
await expectCampaignBiometricsReady(page)
|
||||
await page.goto('/campanha/perfil')
|
||||
|
||||
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
|
||||
// `.nth(0)`: during hydration the server-composed slot can briefly coexist
|
||||
// with its re-rendered copy (same transient duplicate the municipios spec
|
||||
// pins for "Otimista: 5.000") — the copy settles to one element.
|
||||
await expect(page.getByText('Nenhum aparelho cadastrado ainda.').nth(0)).toBeVisible()
|
||||
|
||||
const deviceLabel = page.getByLabel('Nome deste aparelho')
|
||||
await expect(deviceLabel).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ class CampaignE2EOwnership {
|
|||
levelNote: null,
|
||||
strengths: [],
|
||||
risks: [],
|
||||
stateDeputies: [],
|
||||
dobradinhaNotes: null,
|
||||
nextSteps: null,
|
||||
lastUpdateAt: null,
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
|||
import config from '@/payload.config'
|
||||
import { loadAdvisorListPageData } from '@/utilities/advisorData'
|
||||
import { loadDemandListPageData } from '@/utilities/campaignDemandData'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
|
||||
import { loadLeadershipListPageData } from '@/utilities/leadership/leadershipData'
|
||||
import { loadOrganizationListPageData } from '@/utilities/organizationData'
|
||||
import { loadStateDeputyDetail, loadStateDeputyListPageData } from '@/utilities/stateDeputyData'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
|
|
|
|||
303
tests/int/municipalityListRelationFilters.int.spec.ts
Normal file
303
tests/int/municipalityListRelationFilters.int.spec.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// @vitest-environment node
|
||||
|
||||
/**
|
||||
* B176 — the municipality-list relation filters (Dobradinha, Liderança,
|
||||
* Partido) and their edge semantics. Pins the exact drizzle behaviour this
|
||||
* feature depends on: `in: []` matches nothing, `not_in: []` matches
|
||||
* everything, `exists: false` on a hasMany matches the absent array, and the
|
||||
* request-scoped catalog turns the reverse/cross filters into municipality
|
||||
* `where` clauses that scope against the actor.
|
||||
*/
|
||||
import type { Payload, Where } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
buildMunicipalityListWhere,
|
||||
NO_LEADERSHIP_FILTER_VALUE,
|
||||
NO_STATE_DEPUTY_FILTER_VALUE,
|
||||
parseMunicipalityListParams,
|
||||
type MunicipalityListSearchParams,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
import { loadMunicipalityListRelationCatalog } from '@/utilities/municipality/municipalityRelationSets'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
describe('municipality list relation filters (B176)', () => {
|
||||
it('pins the drizzle absence/empty-array semantics on the municipality collection', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const [a, b, c] = await Promise.all([
|
||||
fixtures.getMunicipality(),
|
||||
fixtures.getMunicipality(),
|
||||
fixtures.getMunicipality(),
|
||||
])
|
||||
// `idsFor` returns ascending ids, so the expected scope must be sorted too.
|
||||
const scope = [a.id, b.id, c.id].sort((left, right) => left - right)
|
||||
|
||||
const idsFor = async (extra: Where): Promise<number[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: {
|
||||
and: [{ id: { in: scope } }, extra],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
// `in: []` matches nothing; `not_in: []` matches everything in scope.
|
||||
expect(await idsFor({ id: { in: [] } })).toEqual([])
|
||||
expect(await idsFor({ id: { not_in: [] } })).toEqual(scope)
|
||||
|
||||
// `exists: false`/`in` on the hasMany `stateDeputies` array feeds the
|
||||
// whole dobradinha dimension.
|
||||
expect(await idsFor({ stateDeputies: { exists: false } })).toEqual(scope)
|
||||
expect(await idsFor({ stateDeputies: { exists: true } })).toEqual([])
|
||||
})
|
||||
|
||||
it('filters by dobradinha and "Sem dobradinha" (direct relationship)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const a = await fixtures.getMunicipality()
|
||||
const b = await fixtures.getMunicipality()
|
||||
const c = await fixtures.getMunicipality()
|
||||
const scope = [a.id, b.id, c.id]
|
||||
|
||||
const deputy = await fixtures.createStateDeputy({ name: fixtures.value('Deputado Filtro') })
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: a.id,
|
||||
data: { stateDeputies: [deputy.id] },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: b.id,
|
||||
data: { stateDeputies: [deputy.id] },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.touchMunicipality(a.id)
|
||||
fixtures.touchMunicipality(b.id)
|
||||
|
||||
const state = (raw: MunicipalityListSearchParams): MunicipalityListState =>
|
||||
parseMunicipalityListParams(raw)
|
||||
|
||||
const idsFor = async (extra: Where): Promise<number[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { and: [{ id: { in: scope } }, extra] },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
expect(
|
||||
await idsFor(buildMunicipalityListWhere(state({ stateDeputy: [String(deputy.id)] }))),
|
||||
).toEqual([a.id, b.id])
|
||||
|
||||
expect(
|
||||
await idsFor(
|
||||
buildMunicipalityListWhere(state({ stateDeputy: [NO_STATE_DEPUTY_FILTER_VALUE] })),
|
||||
),
|
||||
).toEqual([c.id])
|
||||
|
||||
// Named OR sentinel in one query: the dobradinha OR no dobradinha.
|
||||
expect(
|
||||
await idsFor(
|
||||
buildMunicipalityListWhere(
|
||||
state({ stateDeputy: [String(deputy.id), NO_STATE_DEPUTY_FILTER_VALUE] }),
|
||||
),
|
||||
),
|
||||
).toEqual([a.id, b.id, c.id])
|
||||
})
|
||||
|
||||
it('filters by leadership and "Sem liderança" through the reverse catalog', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const a = await fixtures.getMunicipality()
|
||||
const b = await fixtures.getMunicipality()
|
||||
const c = await fixtures.getMunicipality()
|
||||
const scope = [a.id, b.id, c.id]
|
||||
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: (await fixtures.createContact()).id,
|
||||
municipalities: [b.id, c.id],
|
||||
})
|
||||
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const catalog = await loadMunicipalityListRelationCatalog(
|
||||
payload,
|
||||
coordinator,
|
||||
parseMunicipalityListParams({ leadership: [String(leadership.id)] }),
|
||||
)
|
||||
|
||||
expect(catalog.leadershipMunicipalityIDsByLeadership.get(leadership.id)).toEqual(
|
||||
expect.arrayContaining([b.id, c.id]),
|
||||
)
|
||||
expect([...catalog.allLeadershipMunicipalityIDs]).toEqual(expect.arrayContaining([b.id, c.id]))
|
||||
|
||||
const state = (raw: MunicipalityListSearchParams): MunicipalityListState =>
|
||||
parseMunicipalityListParams(raw)
|
||||
|
||||
const idsFor = async (extra: Where): Promise<number[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { and: [{ id: { in: scope } }, extra] },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
expect(
|
||||
await idsFor(
|
||||
buildMunicipalityListWhere(state({ leadership: [String(leadership.id)] }), catalog),
|
||||
),
|
||||
).toEqual([b.id, c.id])
|
||||
|
||||
// "Sem liderança" = not linked to ANY scoped leadership → only A.
|
||||
expect(
|
||||
await idsFor(
|
||||
buildMunicipalityListWhere(state({ leadership: [NO_LEADERSHIP_FILTER_VALUE] }), catalog),
|
||||
),
|
||||
).toEqual([a.id])
|
||||
|
||||
// Fails closed without the catalog.
|
||||
expect(() =>
|
||||
buildMunicipalityListWhere(state({ leadership: [NO_LEADERSHIP_FILTER_VALUE] })),
|
||||
).toThrow(/missing relation catalog/)
|
||||
})
|
||||
|
||||
it('filters by party and "Sem partido" through the party→deputy catalog', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const a = await fixtures.getMunicipality()
|
||||
const b = await fixtures.getMunicipality()
|
||||
const c = await fixtures.getMunicipality()
|
||||
const scope = [a.id, b.id, c.id]
|
||||
|
||||
const pt = await fixtures.createStateDeputy({
|
||||
name: fixtures.value('Deputado PT'),
|
||||
party: 'PT',
|
||||
})
|
||||
const pcredo = await fixtures.createStateDeputy({
|
||||
name: fixtures.value('Deputado PCdoB'),
|
||||
party: 'PCdoB',
|
||||
})
|
||||
// A "sem partido" deputy linked to B only.
|
||||
const noParty = await fixtures.createStateDeputy({ name: fixtures.value('Deputado Sem Sigla') })
|
||||
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: a.id,
|
||||
data: { stateDeputies: [pt.id] },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: b.id,
|
||||
data: { stateDeputies: [pcredo.id, noParty.id] },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.touchMunicipality(a.id)
|
||||
fixtures.touchMunicipality(b.id)
|
||||
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const catalog = await loadMunicipalityListRelationCatalog(
|
||||
payload,
|
||||
coordinator,
|
||||
parseMunicipalityListParams({ party: ['PT'] }),
|
||||
)
|
||||
expect([...catalog.allPartyStateDeputyIDs]).toEqual(expect.arrayContaining([pt.id, pcredo.id]))
|
||||
|
||||
const state = (raw: MunicipalityListSearchParams): MunicipalityListState =>
|
||||
parseMunicipalityListParams(raw)
|
||||
|
||||
const idsFor = async (extra: Where): Promise<number[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { and: [{ id: { in: scope } }, extra] },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return result.docs.map((doc) => doc.id).sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
// PT alone → A; PCdoB + "Sem partido" (OR) → B and C (C has no dobradinha).
|
||||
expect(await idsFor(buildMunicipalityListWhere(state({ party: ['PT'] }), catalog))).toEqual([
|
||||
a.id,
|
||||
])
|
||||
|
||||
expect(
|
||||
await idsFor(
|
||||
buildMunicipalityListWhere(state({ party: ['PCdoB', NO_PARTY_FILTER_VALUE] }), catalog),
|
||||
),
|
||||
).toEqual([b.id, c.id])
|
||||
|
||||
// A party with no deputy matches nothing.
|
||||
expect(await idsFor(buildMunicipalityListWhere(state({ party: ['PSD'] }), catalog))).toEqual([])
|
||||
|
||||
expect(() => buildMunicipalityListWhere(state({ party: ['PT'] }))).toThrow(
|
||||
/missing relation catalog/,
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes the leadership facet catalog to the advisor portfolio', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const inScope = await fixtures.getMunicipality()
|
||||
const outOfScope = await fixtures.getMunicipality()
|
||||
|
||||
await fixtures.createLeadership({
|
||||
contact: (await fixtures.createContact()).id,
|
||||
municipalities: [inScope.id],
|
||||
})
|
||||
await fixtures.createLeadership({
|
||||
contact: (await fixtures.createContact()).id,
|
||||
municipalities: [outOfScope.id],
|
||||
})
|
||||
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: inScope.id,
|
||||
data: { advisors: [advisor.id] },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const catalog = await loadMunicipalityListRelationCatalog(
|
||||
payload,
|
||||
advisor,
|
||||
parseMunicipalityListParams({ leadership: ['1'] }),
|
||||
)
|
||||
// Only the administered municipality's leaderships reach the catalog.
|
||||
expect([...catalog.allLeadershipMunicipalityIDs]).not.toContain(outOfScope.id)
|
||||
expect(catalog.allLeadershipMunicipalityIDs.has(inScope.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -310,12 +310,14 @@ describe('campaign visual foundation', () => {
|
|||
// B155: the Lideranças column chips the linked leaderships by name.
|
||||
expect(html).toContain('Lideranças')
|
||||
expect(html).toContain('Maria de Jesus')
|
||||
// B22: every one of the 10 visible-by-default staff columns carries a header explanation —
|
||||
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
|
||||
// the mobile card's own class tooltip (B42) add triggers of their own.
|
||||
// B22 + B176: every one of the 11 visible-by-default staff columns carries
|
||||
// a header explanation — pinned inside `<thead>` itself, since `cellTooltip`
|
||||
// (e.g. "Classe") and the mobile card's own class tooltip (B42) add
|
||||
// triggers of their own. 11 = the 10 staff baseline + Dobradinha, which is
|
||||
// now staff-wide (B176).
|
||||
const [, tbodyHtml = ''] = html.split('<tbody')
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||||
|
||||
// B158: the table is container-driven and clips instead of delegating to a
|
||||
// horizontal scroller. Município remains pinned and owns the territory subline.
|
||||
|
|
@ -393,7 +395,7 @@ describe('campaign visual foundation', () => {
|
|||
expect(html).toContain('>—<')
|
||||
})
|
||||
|
||||
it('keeps the Dobradinhas column out of the advisor and leader views', () => {
|
||||
it('shows the Dobradinhas column to the advisor and hides it from the leader', () => {
|
||||
const municipalities: MunicipalityListViewModel[] = [
|
||||
stub<MunicipalityListViewModel>({
|
||||
id: 1,
|
||||
|
|
@ -423,17 +425,19 @@ describe('campaign visual foundation', () => {
|
|||
}),
|
||||
]
|
||||
|
||||
// B176 (2026-08-09): the Dobradinhas column is staff-wide — an advisor
|
||||
// sees it (the write is scoped to their portfolio server-side), while
|
||||
// the leader lockdown keeps it off the leader view.
|
||||
const advisorHtml = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities,
|
||||
advisorNamesById: new Map(),
|
||||
...municipalityListDefaultProps,
|
||||
isStaffView: true,
|
||||
// Advisor: staff view, but NOT unrestricted — the column must not render.
|
||||
isCampaignUnrestricted: false,
|
||||
}),
|
||||
)
|
||||
expect(advisorHtml).not.toContain('Dobradinhas')
|
||||
expect(advisorHtml).toContain('Dobradinhas')
|
||||
|
||||
const leaderHtml = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
|
|
@ -790,7 +794,6 @@ describe('campaign visual foundation', () => {
|
|||
it('keeps removed and internal columns out of the municipality picker', () => {
|
||||
const columns = municipalityListPickerColumns({
|
||||
isStaffView: true,
|
||||
isCampaignUnrestricted: true,
|
||||
})
|
||||
|
||||
expect(columns.map((column) => column.id)).toEqual([
|
||||
|
|
@ -840,7 +843,9 @@ describe('campaign visual foundation', () => {
|
|||
// The staff `<thead>` already carries 10 header-explanation tooltips
|
||||
// (B22), regardless of row data. This baseline isolates the advisors
|
||||
// CELL tooltip, the one this test actually exercises.
|
||||
const HEADER_TOOLTIP_COUNT = 10
|
||||
// 11 header explanations: the 10 staff columns + the now staff-wide
|
||||
// Dobradinhas (B176).
|
||||
const HEADER_TOOLTIP_COUNT = 11
|
||||
const baseMunicipality = {
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
|
|
|
|||
|
|
@ -479,3 +479,169 @@ describe('buildMunicipalityFilterOptionHref (B16+ fast path)', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('municipality list relation filters (B176)', () => {
|
||||
it('canonicalizes stateDeputy/leadership ids ascending with the sentinel last', () => {
|
||||
const state = parseMunicipalityListParams({
|
||||
stateDeputy: ['5', 'sem_dobradinha', '3', '5', 'abc'],
|
||||
leadership: ['sem_lideranca', '2', '8'],
|
||||
})
|
||||
expect(state.stateDeputies).toEqual([3, 5, 'sem_dobradinha'])
|
||||
expect(state.leaderships).toEqual([2, 8, 'sem_lideranca'])
|
||||
expect(buildMunicipalityListHref(state, 1)).toBe(
|
||||
'/campanha/municipios?stateDeputy=3&stateDeputy=5&stateDeputy=sem_dobradinha&leadership=2&leadership=8&leadership=sem_lideranca',
|
||||
)
|
||||
})
|
||||
|
||||
it('parses parties by insertion order, deduped, with the sentinel last', () => {
|
||||
const state = parseMunicipalityListParams({
|
||||
party: ['PSD', 'PT', 'PSD', 'sem_partido'],
|
||||
})
|
||||
expect(state.parties).toEqual(['PSD', 'PT', 'sem_partido'])
|
||||
expect(buildMunicipalityListHref(state, 1)).toBe(
|
||||
'/campanha/municipios?party=PSD&party=PT&party=sem_partido',
|
||||
)
|
||||
// The sentinel canonicalizes LAST regardless of where it appeared, so one
|
||||
// value set maps to one URL (same contract as the relationship dims).
|
||||
const sentinelFirst = parseMunicipalityListParams({ party: ['sem_partido', 'PT', 'PT'] })
|
||||
expect(sentinelFirst.parties).toEqual(['PT', 'sem_partido'])
|
||||
expect(buildMunicipalityListHref(sentinelFirst, 1)).toBe(
|
||||
'/campanha/municipios?party=PT&party=sem_partido',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes the three dimensions together with the rest of the state', () => {
|
||||
const state = parseMunicipalityListParams({
|
||||
region: 'Irecê',
|
||||
stateDeputy: '4',
|
||||
leadership: ['sem_lideranca', '9'],
|
||||
party: 'PCdoB',
|
||||
})
|
||||
const href = buildMunicipalityListHref(state, 1)
|
||||
expect(href).toContain('stateDeputy=4')
|
||||
expect(href).toContain('leadership=9&leadership=sem_lideranca')
|
||||
expect(href).toContain('party=PCdoB')
|
||||
|
||||
// URL → raw plain-record params → parse, mirroring what the page's
|
||||
// `searchParams` object carries into `parseMunicipalityListParams`.
|
||||
const query = new URLSearchParams(href.split('?')[1])
|
||||
const raw: Record<string, string | string[] | undefined> = {}
|
||||
for (const key of query.keys()) {
|
||||
const values = query.getAll(key)
|
||||
raw[key] = values.length === 1 ? values[0] : values
|
||||
}
|
||||
expect(parseMunicipalityListParams(raw)).toEqual(state)
|
||||
})
|
||||
|
||||
it('builds the direct dobradinha where (in / exists), no catalog needed', () => {
|
||||
expect(buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: '4' }))).toEqual({
|
||||
and: [{ stateDeputies: { in: [4] } }],
|
||||
})
|
||||
expect(
|
||||
buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: 'sem_dobradinha' })),
|
||||
).toEqual({ and: [{ stateDeputies: { exists: false } }] })
|
||||
expect(
|
||||
buildMunicipalityListWhere(
|
||||
parseMunicipalityListParams({ stateDeputy: ['4', 'sem_dobradinha'] }),
|
||||
),
|
||||
).toEqual({
|
||||
and: [{ or: [{ stateDeputies: { in: [4] } }, { stateDeputies: { exists: false } }] }],
|
||||
})
|
||||
})
|
||||
|
||||
const catalog = {
|
||||
leadershipMunicipalityIDsByLeadership: new Map([
|
||||
[1, [10, 11]],
|
||||
[2, [12]],
|
||||
]),
|
||||
allLeadershipMunicipalityIDs: new Set([10, 11, 12]),
|
||||
stateDeputyIDsByParty: new Map([
|
||||
['PT', [4, 5]],
|
||||
['PSD', [6]],
|
||||
]),
|
||||
allPartyStateDeputyIDs: new Set([4, 5, 6]),
|
||||
}
|
||||
|
||||
it('builds the reverse leadership where from the catalog (in / not_in)', () => {
|
||||
expect(
|
||||
buildMunicipalityListWhere(parseMunicipalityListParams({ leadership: ['1', '2'] }), catalog),
|
||||
).toEqual({ and: [{ id: { in: [10, 11, 12] } }] })
|
||||
|
||||
expect(
|
||||
buildMunicipalityListWhere(
|
||||
parseMunicipalityListParams({ leadership: 'sem_lideranca' }),
|
||||
catalog,
|
||||
),
|
||||
).toEqual({ and: [{ id: { not_in: [10, 11, 12] } }] })
|
||||
})
|
||||
|
||||
it('builds the cross party where from the catalog (in / not_in)', () => {
|
||||
expect(
|
||||
buildMunicipalityListWhere(parseMunicipalityListParams({ party: ['PT'] }), catalog),
|
||||
).toEqual({ and: [{ stateDeputies: { in: [4, 5] } }] })
|
||||
|
||||
expect(
|
||||
buildMunicipalityListWhere(parseMunicipalityListParams({ party: 'sem_partido' }), catalog),
|
||||
).toEqual({ and: [{ stateDeputies: { not_in: [4, 5, 6] } }] })
|
||||
|
||||
expect(
|
||||
buildMunicipalityListWhere(
|
||||
parseMunicipalityListParams({ party: ['PT', 'sem_partido'] }),
|
||||
catalog,
|
||||
),
|
||||
).toEqual({
|
||||
and: [{ or: [{ stateDeputies: { in: [4, 5] } }, { stateDeputies: { not_in: [4, 5, 6] } }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed: leadership/party filter without the catalog throws', () => {
|
||||
expect(() =>
|
||||
buildMunicipalityListWhere(parseMunicipalityListParams({ leadership: '1' })),
|
||||
).toThrow(/missing relation catalog/)
|
||||
expect(() => buildMunicipalityListWhere(parseMunicipalityListParams({ party: 'PT' }))).toThrow(
|
||||
/missing relation catalog/,
|
||||
)
|
||||
// Dobradinha (direct) never needs the catalog.
|
||||
expect(() =>
|
||||
buildMunicipalityListWhere(parseMunicipalityListParams({ stateDeputy: '4' })),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('summarizes the three dimensions in the active-filters summary', () => {
|
||||
expect(
|
||||
formatMunicipalityActiveFiltersSummary(
|
||||
parseMunicipalityListParams({ stateDeputy: ['1', '2'] }),
|
||||
),
|
||||
).toBe('2 dobradinhas')
|
||||
expect(
|
||||
formatMunicipalityActiveFiltersSummary(
|
||||
parseMunicipalityListParams({ stateDeputy: ['1', 'sem_dobradinha'] }),
|
||||
),
|
||||
).toBe('1 dobradinha + sem dobradinha')
|
||||
expect(
|
||||
formatMunicipalityActiveFiltersSummary(
|
||||
parseMunicipalityListParams({ leadership: 'sem_lideranca' }),
|
||||
),
|
||||
).toBe('Sem liderança')
|
||||
expect(
|
||||
formatMunicipalityActiveFiltersSummary(
|
||||
parseMunicipalityListParams({ party: ['PT', 'PSD', 'sem_partido'] }),
|
||||
),
|
||||
).toBe('Partido PT, PSD + sem partido')
|
||||
})
|
||||
|
||||
it('keeps the B176 toggles on the canonical fast-path href', () => {
|
||||
const state = parseMunicipalityListParams({ party: 'PT' })
|
||||
expect(buildMunicipalityFilterOptionHref(state, 'stateDeputy', '5')).toBe(
|
||||
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, 'stateDeputy', '5')),
|
||||
)
|
||||
expect(buildMunicipalityFilterOptionHref(state, 'leadership', 'sem_lideranca')).toBe(
|
||||
buildMunicipalityFilterHref(
|
||||
toggleMunicipalityMultiFilterValue(state, 'leadership', 'sem_lideranca'),
|
||||
),
|
||||
)
|
||||
expect(buildMunicipalityFilterOptionHref(state, 'party', 'PSD')).toBe(
|
||||
buildMunicipalityFilterHref(toggleMunicipalityMultiFilterValue(state, 'party', 'PSD')),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ describe('municipality omnibox (B127)', () => {
|
|||
scenario: 'optimistic',
|
||||
showStaffFilters: true,
|
||||
advisorLabelsById: new Map(),
|
||||
stateDeputyLabelsById: new Map(),
|
||||
leadershipLabelsById: new Map(),
|
||||
})
|
||||
expect(chips.map((chip) => chip.id)).toEqual([
|
||||
'q',
|
||||
|
|
@ -43,6 +45,8 @@ describe('municipality omnibox (B127)', () => {
|
|||
scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
showStaffFilters: true,
|
||||
advisorLabelsById: new Map(),
|
||||
stateDeputyLabelsById: new Map(),
|
||||
leadershipLabelsById: new Map(),
|
||||
})
|
||||
expect(chips).toEqual([])
|
||||
})
|
||||
|
|
@ -54,6 +58,9 @@ describe('municipality omnibox (B127)', () => {
|
|||
regionFilterOptions: [],
|
||||
advisorFilterOptions: [],
|
||||
slugFilterOptions: [{ value: 'salvador', label: 'Salvador' }],
|
||||
stateDeputyFilterOptions: [],
|
||||
leadershipFilterOptions: [],
|
||||
partyFilterOptions: [],
|
||||
})
|
||||
expect(suggestions.some((entry) => entry.id === 'q:pri')).toBe(true)
|
||||
expect(suggestions.some((entry) => entry.id === 'priority:alta')).toBe(true)
|
||||
|
|
@ -66,10 +73,93 @@ describe('municipality omnibox (B127)', () => {
|
|||
regionFilterOptions: [],
|
||||
advisorFilterOptions: [],
|
||||
slugFilterOptions: [],
|
||||
stateDeputyFilterOptions: [],
|
||||
leadershipFilterOptions: [],
|
||||
partyFilterOptions: [],
|
||||
})
|
||||
expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true)
|
||||
})
|
||||
|
||||
it('suggests Dobradinha, Liderança and Partido options (B176)', () => {
|
||||
const suggestions = buildMunicipalityOmniboxSuggestions({
|
||||
query: 'mar',
|
||||
showStaffFilters: true,
|
||||
regionFilterOptions: [],
|
||||
advisorFilterOptions: [],
|
||||
slugFilterOptions: [],
|
||||
stateDeputyFilterOptions: [{ value: '12', label: 'Maria Souza (PSD)' }],
|
||||
leadershipFilterOptions: [{ value: '7', label: 'Maria de Fátima' }],
|
||||
partyFilterOptions: [{ value: 'PSD', label: 'PSD' }],
|
||||
})
|
||||
expect(suggestions.some((entry) => entry.id === 'stateDeputy:12')).toBe(true)
|
||||
expect(suggestions.some((entry) => entry.id === 'leadership:7')).toBe(true)
|
||||
// Typing a party acronym matches the label itself.
|
||||
const byParty = buildMunicipalityOmniboxSuggestions({
|
||||
query: 'psd',
|
||||
showStaffFilters: true,
|
||||
regionFilterOptions: [],
|
||||
advisorFilterOptions: [],
|
||||
slugFilterOptions: [],
|
||||
stateDeputyFilterOptions: [],
|
||||
leadershipFilterOptions: [],
|
||||
partyFilterOptions: [{ value: 'PSD', label: 'PSD' }],
|
||||
})
|
||||
expect(byParty.some((entry) => entry.id === 'party:PSD')).toBe(true)
|
||||
})
|
||||
|
||||
it('reveals the "Sem …" sentinel options while typing "sem"', () => {
|
||||
const suggestions = buildMunicipalityOmniboxSuggestions({
|
||||
query: 'sem',
|
||||
showStaffFilters: true,
|
||||
regionFilterOptions: [],
|
||||
advisorFilterOptions: [],
|
||||
slugFilterOptions: [],
|
||||
stateDeputyFilterOptions: [],
|
||||
leadershipFilterOptions: [],
|
||||
partyFilterOptions: [],
|
||||
})
|
||||
expect(suggestions.map((entry) => entry.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'stateDeputy:sem_dobradinha',
|
||||
'leadership:sem_lideranca',
|
||||
'party:sem_partido',
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the B176 relation filters inclusively and removes single chips', () => {
|
||||
const applied = applyMunicipalityOmniboxSuggestion({
|
||||
state: base,
|
||||
suggestionId: 'stateDeputy:12',
|
||||
})
|
||||
expect(applied.kind).toBe('url')
|
||||
if (applied.kind !== 'url') return
|
||||
expect(applied.state.stateDeputies).toEqual([12])
|
||||
|
||||
const withSentinel = applyMunicipalityOmniboxSuggestion({
|
||||
state: applied.state,
|
||||
suggestionId: 'leadership:sem_lideranca',
|
||||
})
|
||||
expect(withSentinel.kind).toBe('url')
|
||||
if (withSentinel.kind === 'url') {
|
||||
expect(withSentinel.state.leaderships).toEqual(['sem_lideranca'])
|
||||
}
|
||||
|
||||
const withParty = applyMunicipalityOmniboxSuggestion({
|
||||
state: base,
|
||||
suggestionId: 'party:PSD',
|
||||
})
|
||||
expect(withParty.kind).toBe('url')
|
||||
if (withParty.kind === 'url') expect(withParty.state.parties).toEqual(['PSD'])
|
||||
|
||||
const removed = removeMunicipalityOmniboxChip({
|
||||
state: parseMunicipalityListParams({ stateDeputy: ['12', '5'] }),
|
||||
chipId: 'stateDeputy:5',
|
||||
})
|
||||
expect(removed.kind).toBe('url')
|
||||
if (removed.kind === 'url') expect(removed.state.stateDeputies).toEqual([12])
|
||||
})
|
||||
|
||||
it('applies text search and toggles inclusive filters', () => {
|
||||
const search = applyMunicipalityOmniboxSuggestion({
|
||||
state: base,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
clearStateDeputyListFilters,
|
||||
clearStateDeputyPartyFilter,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
import {
|
||||
buildStateDeputyListHref,
|
||||
buildStateDeputySortHref,
|
||||
NO_PARTY_FILTER_VALUE,
|
||||
parseStateDeputyListParams,
|
||||
parseStateDeputySortValue,
|
||||
resolveStateDeputyListPayloadSort,
|
||||
|
|
|
|||
Loading…
Reference in a new issue