mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Remove Tipo column and list kind filter from municipality list.
Drop the redundant type column, URL param, and filters while keeping municipality.kind for map and dossier; update tests and delivery docs.
This commit is contained in:
parent
c283cc5202
commit
ee44581ba4
17 changed files with 52 additions and 151 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
|
||||
- **B18 entregue em código (2026-07-28):** filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para `<body>`, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `<ul>` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
|
||||
- **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md).
|
||||
- **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Remover coluna Tipo da lista de Municípios
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Status: entregue
|
||||
Atualizado em: 2026-07-29
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins)
|
||||
Impeccable: B — encaixe em `MunicipalityList` / filtros da lista (`/campanha/municipios`); sem rota nova
|
||||
Appetite: ~0,25–0,5 dia eng; remove coluna + filtro/sort `kind` da URL da lista; sem migration
|
||||
|
|
@ -105,8 +105,8 @@ Depth check: só delete/estreitamento nos módulos profundos já donos do contra
|
|||
|
||||
- `docs/roadmap.md` (Fill-ins abertos)
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` — coluna `kind` (~L262–270)
|
||||
- `src/utilities/municipalityListUrl.ts` — contrato URL / sort / where
|
||||
- `src/utilities/municipalityListFilters.ts` — definition + `applyMunicipalityKindFilter`
|
||||
- `src/utilities/municipality/municipalityListUrl.ts` — contrato URL / sort / where (Pass 2 W2)
|
||||
- `src/utilities/municipality/municipalityListFilters.ts` — definitions (sem `kind` desde 2026-07-29)
|
||||
- `src/components/campaign/municipality/MunicipalityHeaderFilter.tsx` — single-select Tipo
|
||||
- `src/components/campaign/municipality/MunicipalityFilters.tsx` — mobile
|
||||
- `src/utilities/municipalityLabels.ts` — `municipalityKindLabels` (permanece para detalhe)
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
|
||||
- ~~**Limitar o mapa da Bahia a pan/zoom dentro do estado**~~ — **✓ (entregue 2026-07-26)** `BahiaMap` trava pan com `maxBounds` (`BAHIA_BOUNDS.pad(0.1)`) + `maxBoundsViscosity: 1` e zoom-out com `minZoom` via `getBoundsZoom(BAHIA_BOUNDS, false, L.point(16, 16))` recalculado no `ResizeObserver` existente; único arquivo tocado; sem migration/props · [plano](plans/limitar-mapa-a-bahia.md)
|
||||
- ~~**Renomear coluna Cobertura da meta → Cobertura**~~ — **✓ (entregue 2026-07-28)** em `/campanha/municipios`: `municipalityListSortLabels.deficit` → "Cobertura" (B17 ✓ propaga header, picker e sort); card mobile via `municipalityColumnLabels.goalCoverage`; `whereItAppears` do conceito E18 atualizado; strips/card/título do glossário e `/campanha/territorios` intactos · [plano](plans/renomear-coluna-cobertura-meta.md)
|
||||
- **Remover coluna Tipo** em `/campanha/municipios` — pedido de produto (2026-07-25): a coluna "Tipo" (Município × Zona eleitoral) não informa a coordenação (ambos tratados iguais); filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + param `?kind=` do contrato URL da lista; **schema/catálogo `kind` intacto** (mapa, B8, dossiê) · ~0,25–0,5d · Impeccable B · soft: B15 ✓/B16 ✓ (header); B17 ganha uma coluna a menos · cortável · [plano](plans/remover-coluna-tipo-municipios.md)
|
||||
- ~~**Remover coluna Tipo** em `/campanha/municipios`~~ — **✓ (entregue 2026-07-29)** coluna "Tipo" + filtro/sort no header e mobile + param `?kind=` do contrato URL da lista removidos; `municipality.kind` e `municipalityKindLabels` intactos (mapa, B8, dossiê); bookmarks `?kind=` viram no-op no parse; testes B17/B18 migrados para Território/`?priority=alta`; módulos em `src/utilities/municipality/` · [plano](plans/remover-coluna-tipo-municipios.md)
|
||||
- ~~**Remover lista "Municípios por zona eleitoral" do Início**~~ — **✓ (entregue 2026-07-28)** bloco do painel + `zoneBreakdown` do bundle removidos; ressalva de aproximação permanece via `hasZoneMunicipalities` (B8+ F3 ✓) · [plano](plans/remover-lista-zonas-mapa-inicio.md)
|
||||
- ~~**Dimir o resultado no compositor de giro**~~ — **✓ (entregue 2026-07-28)** em `/campanha/atividades/giros`: `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta (`data-pending`/`aria-busy`/`aria-live`), `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` moveu para dentro de Results; unit `tourRegionPending.unit.spec.ts` · sem migration · [plano](plans/feedback-pendente-compositor-giro.md)
|
||||
- ~~**Ícone de prioridade na lista**~~ ✓ (entregue 2026-07-28) em `/campanha/municipios` — Badge “Prioritária” trocado por Flag + `CampaignHoverTooltip` “Município prioritário” na coluna do nome (tabela + card mobile) · ~0,25d · Impeccable B · [plano](plans/icone-prioridade-lista-municipios.md)
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ import { Button } from '@/components/ui/button'
|
|||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import {
|
||||
applyMunicipalityKindFilter,
|
||||
buildMunicipalityFilterHref,
|
||||
clearMunicipalityListFilters,
|
||||
formatMunicipalityActiveFiltersSummary,
|
||||
getMunicipalityFilterDefinition,
|
||||
getMunicipalitySingleFilterValue,
|
||||
municipalityFilterDefinitions,
|
||||
toggleMunicipalityExclusiveFilterValue,
|
||||
toggleMunicipalityMultiFilterValue,
|
||||
|
|
@ -130,10 +128,7 @@ export const MunicipalityFilters = ({
|
|||
/>
|
||||
) : null}
|
||||
{mobileFilterDefinitions.map((definition) => {
|
||||
const value =
|
||||
definition.param === 'kind' || definition.param === 'coverage'
|
||||
? (getMunicipalitySingleFilterValue(state, definition.param) ?? '')
|
||||
: ''
|
||||
const value = definition.param === 'coverage' ? (state.coverage ?? '') : ''
|
||||
return (
|
||||
<Field key={definition.param}>
|
||||
<FieldLabel htmlFor={`municipality-filter-${definition.param}`}>
|
||||
|
|
@ -150,10 +145,6 @@ export const MunicipalityFilters = ({
|
|||
? toggleMunicipalityExclusiveFilterValue(state, 'coverage', selected)
|
||||
: { ...state, coverage: undefined },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (definition.param === 'kind') {
|
||||
navigateWithSearch(applyMunicipalityKindFilter(state, selected))
|
||||
}
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
|
||||
import { municipalityPriorityLabels } from '@/utilities/municipality/municipalityLabels'
|
||||
import {
|
||||
applyMunicipalityKindFilter,
|
||||
buildMunicipalityFilterHref,
|
||||
buildMunicipalityFilterOptionHref,
|
||||
clearMunicipalityAdvisorFilters,
|
||||
|
|
@ -16,7 +15,6 @@ import {
|
|||
clearMunicipalityNameFilters,
|
||||
getMunicipalityFilterDefinition,
|
||||
getMunicipalityMultiFilterValues,
|
||||
getMunicipalitySingleFilterValue,
|
||||
isMunicipalityColumnFilterActive,
|
||||
municipalityFilterOptionsForSlugs,
|
||||
toggleMunicipalityExclusiveFilterValue,
|
||||
|
|
@ -48,7 +46,6 @@ const multiParamByFilter = {
|
|||
trend: 'trend',
|
||||
class: 'class',
|
||||
level: 'level',
|
||||
kind: null,
|
||||
coverage: null,
|
||||
} as const satisfies Record<MunicipalityFilterParam, MunicipalityMultiFilterParam | null>
|
||||
|
||||
|
|
@ -123,35 +120,6 @@ export const MunicipalityHeaderFilter = ({
|
|||
onChoose: () => commit(row.next),
|
||||
}))
|
||||
|
||||
const singleRows: CampaignHeaderFilterRow[] =
|
||||
definition.selection === 'single'
|
||||
? [
|
||||
...(definition.allLabel
|
||||
? [
|
||||
{
|
||||
value: '',
|
||||
label: definition.allLabel,
|
||||
href: buildMunicipalityFilterHref(
|
||||
applyMunicipalityKindFilter(viewState, undefined),
|
||||
),
|
||||
selected: !getMunicipalitySingleFilterValue(viewState, 'kind'),
|
||||
onChoose: () => commit(applyMunicipalityKindFilter(viewState, undefined)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...options.map((option) => {
|
||||
const next = applyMunicipalityKindFilter(viewState, option.value)
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: buildMunicipalityFilterHref(next),
|
||||
selected: getMunicipalitySingleFilterValue(viewState, 'kind') === option.value,
|
||||
onChoose: () => commit(next),
|
||||
}
|
||||
}),
|
||||
]
|
||||
: []
|
||||
|
||||
const multiRows: CampaignHeaderFilterRow[] = multiParam
|
||||
? options.map((option) => ({
|
||||
value: option.value,
|
||||
|
|
@ -171,7 +139,7 @@ export const MunicipalityHeaderFilter = ({
|
|||
active={isActive}
|
||||
closeOnChoose={definition.selection !== 'multi'}
|
||||
exclusiveRows={sharedExclusiveRows}
|
||||
optionRows={definition.selection === 'single' ? singleRows : multiRows}
|
||||
optionRows={multiRows}
|
||||
clear={
|
||||
hasSelection
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ import {
|
|||
formatTerritorialClassWhy,
|
||||
municipalityColumnDescriptions,
|
||||
municipalityGeographyParts,
|
||||
municipalityKindLabels,
|
||||
territorialClassBadgeVariant,
|
||||
territorialClassLabels,
|
||||
type MunicipalityListColumnId,
|
||||
|
|
@ -311,19 +310,6 @@ const municipalityListColumns = ({
|
|||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (municipality) => <TerritoryLink region={municipality.region} />,
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
label: municipalityColumnLabels.kind,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="kind"
|
||||
filterParam="kind"
|
||||
description={municipalityColumnDescriptions.kind}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => municipalityKindLabels[municipality.kind],
|
||||
},
|
||||
{
|
||||
id: 'votos',
|
||||
label: municipalityColumnLabels.votos,
|
||||
|
|
|
|||
|
|
@ -189,14 +189,13 @@ export const formatMunicipalityGeographyLabel = (municipality: {
|
|||
}
|
||||
|
||||
/**
|
||||
* The 12 real column ids in `/campanha/municipios` — 11 staff columns plus
|
||||
* The 11 real column ids in `/campanha/municipios` — 10 staff columns plus
|
||||
* `lastUpdateAt`, which only the non-staff (leader) view renders. Closed union
|
||||
* so `municipalityColumnDescriptions` below can't silently drop a column.
|
||||
*/
|
||||
export type MunicipalityListColumnId =
|
||||
| 'name'
|
||||
| 'region'
|
||||
| 'kind'
|
||||
| 'votos'
|
||||
| 'classe'
|
||||
// Matches the column's own `id` and its `?level=` filter param; the SORT key
|
||||
|
|
@ -228,7 +227,6 @@ const CLASS_COLUMN_DESCRIPTION =
|
|||
export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, string> = {
|
||||
name: 'Nome do município e prioridade da campanha.',
|
||||
region: 'Território de Identidade (Bahia) a que o município pertence.',
|
||||
kind: 'Município inteiro ou zona eleitoral de Salvador (ZE 1–19).',
|
||||
votos: formatMunicipalityConcentrationHint(),
|
||||
classe: CLASS_COLUMN_DESCRIPTION,
|
||||
level: campaignConceptOneLiner('nivel-de-envolvimento'),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { EMPTY_ENGAGEMENT_LEVEL_LABEL, formatEngagementLevelLabel } from '@/lib/
|
|||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
municipalityKindLabels,
|
||||
municipalityListCoverageLabels,
|
||||
municipalityPriorityLabels,
|
||||
politicalTrendLabels,
|
||||
|
|
@ -31,7 +30,6 @@ import type { MunicipalityTerritorialClass } from '@/utilities/municipality/muni
|
|||
export type MunicipalityFilterParam =
|
||||
| 'name'
|
||||
| 'region'
|
||||
| 'kind'
|
||||
| 'coverage'
|
||||
| 'trend'
|
||||
| 'class'
|
||||
|
|
@ -75,16 +73,6 @@ export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
|
|||
staffOnly: false,
|
||||
selection: 'multi',
|
||||
},
|
||||
{
|
||||
param: 'kind',
|
||||
label: 'Tipo',
|
||||
allLabel: 'Todos',
|
||||
staffOnly: false,
|
||||
selection: 'single',
|
||||
options: (
|
||||
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
|
||||
).map((kind) => ({ value: kind, label: municipalityKindLabels[kind] })),
|
||||
},
|
||||
{
|
||||
param: 'advisor',
|
||||
label: 'Assessores',
|
||||
|
|
@ -149,11 +137,6 @@ export const getMunicipalityFilterDefinition = (
|
|||
param: MunicipalityFilterParam,
|
||||
): MunicipalityFilterDefinition => municipalityFilterDefinitionByParam[param]
|
||||
|
||||
export const getMunicipalitySingleFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
param: 'kind' | 'coverage',
|
||||
): string | undefined => state[param]
|
||||
|
||||
export type MunicipalityMultiFilterParam =
|
||||
| 'region'
|
||||
| 'slug'
|
||||
|
|
@ -177,16 +160,6 @@ export const getMunicipalityMultiFilterValues = (
|
|||
const withMunicipalityListPageReset = (state: MunicipalityListState): MunicipalityListState =>
|
||||
parseMunicipalityListParams(municipalityListStateToRawParams({ ...state, page: 1 }, 1))
|
||||
|
||||
/** Exclusive single-value set/clear for `kind`. Empty (or invalid) clears. */
|
||||
export const applyMunicipalityKindFilter = (
|
||||
state: MunicipalityListState,
|
||||
value: string | undefined,
|
||||
): MunicipalityListState =>
|
||||
parseMunicipalityListParams({
|
||||
...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
kind: value || undefined,
|
||||
})
|
||||
|
||||
/** Toggle exclusivity: clicking the active value clears (coverage). */
|
||||
export const toggleMunicipalityExclusiveFilterValue = (
|
||||
state: MunicipalityListState,
|
||||
|
|
@ -337,8 +310,6 @@ export const isMunicipalityColumnFilterActive = (
|
|||
case 'advisor':
|
||||
// The Assessores popover also owns the com/sem assessor toggle.
|
||||
return Boolean(state.advisors?.length || state.coverage)
|
||||
case 'kind':
|
||||
return Boolean(state.kind)
|
||||
case 'coverage':
|
||||
return Boolean(state.coverage)
|
||||
case 'trend':
|
||||
|
|
@ -363,7 +334,6 @@ export const formatMunicipalityActiveFiltersSummary = (
|
|||
)
|
||||
}
|
||||
if (state.regions?.length) parts.push(truncatedNamesLabel([...state.regions]))
|
||||
if (state.kind) parts.push(municipalityKindLabels[state.kind])
|
||||
if (state.advisors?.length) {
|
||||
parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,11 @@ import {
|
|||
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
|
||||
import { parseTerritoryRegionsParam } from '@/utilities/territory/territoryRegionParam'
|
||||
|
||||
import type { Municipality } from '@/payload-types'
|
||||
|
||||
export const municipalityPageSize = 25
|
||||
|
||||
export type MunicipalityListSortKey =
|
||||
| 'name'
|
||||
| 'region'
|
||||
| 'kind'
|
||||
| 'trend'
|
||||
| 'expectedVotes'
|
||||
| 'lastUpdateAt'
|
||||
|
|
@ -80,7 +77,6 @@ const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'deficit'
|
|||
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
|
||||
name: 'Município',
|
||||
region: 'Território',
|
||||
kind: 'Tipo',
|
||||
trend: 'Tendência',
|
||||
expectedVotes: 'Votos estimados',
|
||||
lastUpdateAt: 'Última atualização',
|
||||
|
|
@ -95,8 +91,8 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
}
|
||||
|
||||
/**
|
||||
* B17 — the name each column answers to in the column picker. Ten of the
|
||||
* twelve quote the sort label, and the header renders that same record
|
||||
* B17 — the name each column answers to in the column picker. Nine of the
|
||||
* eleven quote the sort label, and the header renders that same record
|
||||
* (`MunicipalitySortableHead` falls back to it when given no children, which
|
||||
* is why those heads pass none), so renaming a header renames the menu entry
|
||||
* with it. The two that differ do so on purpose: a header sits above its own
|
||||
|
|
@ -106,7 +102,6 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
|
||||
name: municipalityListSortLabels.name,
|
||||
region: municipalityListSortLabels.region,
|
||||
kind: municipalityListSortLabels.kind,
|
||||
/** Header is the bare year, under the "2022" group of the table. */
|
||||
votos: 'Votação 2022',
|
||||
classe: municipalityListSortLabels.classe,
|
||||
|
|
@ -129,7 +124,6 @@ export type MunicipalityListState = {
|
|||
slugs?: string[]
|
||||
/** Multi-select (OR) campaignUser advisor IDs. */
|
||||
advisors?: number[]
|
||||
kind?: Municipality['kind']
|
||||
coverage?: 'com_assessor' | 'sem_assessor'
|
||||
priority?: 'alta'
|
||||
/**
|
||||
|
|
@ -163,7 +157,6 @@ const municipalityListParamNames = [
|
|||
'region',
|
||||
'slug',
|
||||
'advisor',
|
||||
'kind',
|
||||
'coverage',
|
||||
'priority',
|
||||
'trend',
|
||||
|
|
@ -279,7 +272,6 @@ export const municipalityListStateToRawParams = (
|
|||
region: state.regions,
|
||||
slug: state.slugs,
|
||||
advisor: state.advisors?.map(String),
|
||||
kind: state.kind,
|
||||
coverage: state.coverage,
|
||||
priority: state.priority,
|
||||
trend: state.trends,
|
||||
|
|
@ -298,7 +290,6 @@ export const parseMunicipalityListParams = (
|
|||
const regions = parseRegionsParam(params.region)
|
||||
const slugs = parseSlugsParam(params.slug)
|
||||
const advisors = parseAdvisorsParam(params.advisor)
|
||||
const rawKind = firstValue(params.kind)
|
||||
const rawCoverage = firstValue(params.coverage)
|
||||
const rawPriority = firstValue(params.priority)
|
||||
const trends = parseExhaustiveEnumParam<PoliticalTrendStatus>(
|
||||
|
|
@ -325,7 +316,6 @@ export const parseMunicipalityListParams = (
|
|||
...(regions.length ? { regions } : {}),
|
||||
...(slugs.length ? { slugs } : {}),
|
||||
...(advisors.length ? { advisors } : {}),
|
||||
...(rawKind === 'municipio' || rawKind === 'zona' ? { kind: rawKind } : {}),
|
||||
...(rawCoverage === 'com_assessor' || rawCoverage === 'sem_assessor'
|
||||
? { coverage: rawCoverage }
|
||||
: {}),
|
||||
|
|
@ -353,7 +343,6 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
|
|||
if (state.regions?.length) filters.push({ region: { in: state.regions } })
|
||||
if (state.slugs?.length) filters.push({ slug: { in: state.slugs } })
|
||||
if (state.advisors?.length) filters.push({ advisors: { in: state.advisors } })
|
||||
if (state.kind) filters.push({ kind: { equals: state.kind } })
|
||||
if (state.coverage) {
|
||||
filters.push({
|
||||
advisors: { exists: state.coverage === 'com_assessor' },
|
||||
|
|
@ -402,7 +391,6 @@ export const serializeCanonicalMunicipalityListSearchParams = (
|
|||
for (const region of canonicalState.regions ?? []) params.append('region', region)
|
||||
for (const slug of canonicalState.slugs ?? []) params.append('slug', slug)
|
||||
for (const advisor of canonicalState.advisors ?? []) params.append('advisor', String(advisor))
|
||||
if (canonicalState.kind) params.set('kind', canonicalState.kind)
|
||||
if (canonicalState.coverage) params.set('coverage', canonicalState.coverage)
|
||||
if (canonicalState.priority) params.set('priority', canonicalState.priority)
|
||||
for (const trend of canonicalState.trends ?? []) params.append('trend', trend)
|
||||
|
|
|
|||
|
|
@ -195,7 +195,6 @@ const loadMunicipalityListFilterFacets = async (
|
|||
const payloadSortFieldByKey: Partial<Record<MunicipalityListSortKey, string>> = {
|
||||
name: 'name',
|
||||
region: 'region',
|
||||
kind: 'kind',
|
||||
lastUpdateAt: 'lastUpdateAt',
|
||||
trend: 'politicalTrend.status',
|
||||
// E9 (deficit, frescor), E10 (classe) and E14 (nivel) are absent on purpose:
|
||||
|
|
|
|||
|
|
@ -36,20 +36,20 @@ test.describe('Seletor de colunas', () => {
|
|||
await campaign.login(page, email, password)
|
||||
await page.goto('/campanha/municipios')
|
||||
|
||||
const kindHeader = page.getByRole('columnheader', { name: /Tipo/ })
|
||||
await expect(kindHeader).toBeVisible()
|
||||
const regionHeader = page.getByRole('columnheader', { name: /Território/ })
|
||||
await expect(regionHeader).toBeVisible()
|
||||
|
||||
const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
|
||||
await picker.click()
|
||||
await page.getByRole('checkbox', { name: 'Tipo' }).click()
|
||||
await page.getByRole('checkbox', { name: 'Território' }).click()
|
||||
// Closing flushes the batched cookie write and refreshes the RSC.
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(kindHeader).toHaveCount(0, REFRESH)
|
||||
await expect(regionHeader).toHaveCount(0, REFRESH)
|
||||
await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('columnheader', { name: /Tipo/ })).toHaveCount(0, REFRESH)
|
||||
await expect(page.getByRole('columnheader', { name: /Território/ })).toHaveCount(0, REFRESH)
|
||||
|
||||
// The cookie carries one key per list: another list is untouched. The
|
||||
// territories list is the honest neighbour here — its 27 rows come from
|
||||
|
|
@ -63,7 +63,7 @@ test.describe('Seletor de colunas', () => {
|
|||
await page.getByRole('button', { name: 'Restaurar todas' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: /Tipo/ })).toBeVisible(REFRESH)
|
||||
await expect(page.getByRole('columnheader', { name: /Território/ })).toBeVisible(REFRESH)
|
||||
})
|
||||
|
||||
test('lists the mandatory column without letting it be unchecked', async ({ campaign, page }) => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
|||
*/
|
||||
|
||||
/** 416 whole municipalities — deep enough that `?page=2` is a real page. */
|
||||
const SAVED_RECORTE = '/campanha/municipios?kind=municipio'
|
||||
const SAVED_RECORTE = '/campanha/municipios?priority=alta'
|
||||
|
||||
const SAVED_NAME = 'Recorte do teste'
|
||||
const RENAMED = 'Recorte renomeado'
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
|
|||
it('paints each zone under its own map key, never pooled under the city code', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
||||
const bundle = await loadMunicipalityMapBundle(payload, coordinator, { kind: 'zona' })
|
||||
const bundle = await loadMunicipalityMapBundle(payload, coordinator, {
|
||||
slug: zoneMunicipalities.map((entry) => entry.slug),
|
||||
})
|
||||
|
||||
expect(bundle).not.toBeNull()
|
||||
expect(bundle!.hasZoneMunicipalities).toBe(true)
|
||||
|
|
@ -73,10 +75,9 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
|
|||
expect(Object.keys(included!.municipalitiesByMapKey)).toEqual([expectedMapKey])
|
||||
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([expectedMapKey])
|
||||
|
||||
const excluded =
|
||||
administered.kind === 'zona'
|
||||
? await loadMunicipalityMapBundle(payload, advisor, { kind: 'municipio' })
|
||||
: await loadMunicipalityMapBundle(payload, advisor, { kind: 'zona' })
|
||||
const outsideSlug = municipalityCatalog.find((entry) => entry.slug !== administered.slug)!.slug
|
||||
|
||||
const excluded = await loadMunicipalityMapBundle(payload, advisor, { slug: [outsideSlug] })
|
||||
expect(excluded).toBeNull()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -395,10 +395,11 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
expect(included.overview).not.toBeNull()
|
||||
expect(included.municipalities.some((row) => row.slug === administered.slug)).toBe(true)
|
||||
|
||||
const excluded =
|
||||
administered.kind === 'zona'
|
||||
? await loadMunicipalityListPageBundle(payload, advisor, { kind: 'municipio' })
|
||||
: await loadMunicipalityListPageBundle(payload, advisor, { kind: 'zona' })
|
||||
const outsideSlug = municipalityCatalog.find((entry) => entry.slug !== administered.slug)!.slug
|
||||
|
||||
const excluded = await loadMunicipalityListPageBundle(payload, advisor, {
|
||||
slug: [outsideSlug],
|
||||
})
|
||||
expect(excluded.overview?.municipalityCount).toBe(0)
|
||||
expect(excluded.municipalities).toHaveLength(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ let cookieJar = ''
|
|||
|
||||
const COLUMNS = [
|
||||
{ id: 'name', label: 'Município', mandatory: true },
|
||||
{ id: 'kind', label: 'Tipo' },
|
||||
{ id: 'region', label: 'Território' },
|
||||
{ id: 'trend', label: 'Tendência' },
|
||||
]
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ describe('CampaignColumnPicker', () => {
|
|||
render(picker([]))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
// Reading the next label takes longer than any debounce worth having:
|
||||
// a second between checkboxes must still be one refresh, not two.
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
|
@ -84,7 +84,7 @@ describe('CampaignColumnPicker', () => {
|
|||
closeMenu()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind~trend')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:region~trend')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
|
@ -96,14 +96,14 @@ describe('CampaignColumnPicker', () => {
|
|||
try {
|
||||
render(picker([]))
|
||||
openMenu()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
// The idle timer is durability, not the commit: a slow reader who takes
|
||||
// more than the interval between checkboxes must not pay a route render
|
||||
// per column, which is the failure the 400 ms debounce was rejected for.
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:region')
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
||||
closeMenu()
|
||||
|
|
@ -114,11 +114,11 @@ describe('CampaignColumnPicker', () => {
|
|||
})
|
||||
|
||||
it('does not refresh a session that ends where it started', () => {
|
||||
render(picker(['kind']))
|
||||
render(picker(['region']))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
closeMenu()
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
|
@ -130,10 +130,10 @@ describe('CampaignColumnPicker', () => {
|
|||
|
||||
// Radix reports no close event here, so without the unmount flush this
|
||||
// choice would be lost to a sidebar navigation with no feedback at all.
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
unmount()
|
||||
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:region')
|
||||
})
|
||||
|
||||
it('never lets the mandatory column be unchecked', () => {
|
||||
|
|
@ -150,7 +150,7 @@ describe('CampaignColumnPicker', () => {
|
|||
const { rerender } = render(picker([]))
|
||||
openMenu()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tipo' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Território' }))
|
||||
closeMenu()
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
|
||||
|
||||
|
|
@ -158,8 +158,8 @@ describe('CampaignColumnPicker', () => {
|
|||
openMenu()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' }))
|
||||
// … and the RSC payload of the FIRST one lands inside it. Adopting it here
|
||||
// would uncheck nothing and re-commit `kind` alone, dropping `trend`.
|
||||
rerender(picker(['kind']))
|
||||
// would uncheck nothing and re-commit `region` alone, dropping `trend`.
|
||||
rerender(picker(['region']))
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'Tendência' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
|
|
@ -167,19 +167,19 @@ describe('CampaignColumnPicker', () => {
|
|||
|
||||
closeMenu()
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2))
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:kind~trend')
|
||||
expect(cookieJar).toContain('campaign_columns=municipios:region~trend')
|
||||
})
|
||||
|
||||
it('adopts the server payload once nothing is pending', async () => {
|
||||
const { rerender } = render(picker([]))
|
||||
|
||||
rerender(picker(['kind']))
|
||||
rerender(picker(['region']))
|
||||
openMenu()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('checkbox', { name: 'Tipo' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
),
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Território' }).getAttribute('aria-checked'),
|
||||
).toBe('false'),
|
||||
)
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ describe('campaign visual foundation', () => {
|
|||
// the mobile card's own class tooltip (B42) add triggers of their own.
|
||||
const [, tbodyHtml = ''] = html.split('<tbody')
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
|
||||
|
||||
// B41: horizontal scroll + sticky Município (Territory pattern).
|
||||
expect(html).toMatch(/data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/)
|
||||
|
|
@ -521,7 +521,7 @@ describe('campaign visual foundation', () => {
|
|||
// B22: the leader view's 5 columns (name/region/kind/votos/lastUpdateAt)
|
||||
// also carry header explanations.
|
||||
const [theadHtml] = html.split('<tbody')
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(5)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(4)
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -553,7 +553,7 @@ describe('campaign visual foundation', () => {
|
|||
// The staff `<thead>` already carries 11 header-explanation tooltips
|
||||
// (B22), regardless of row data. This baseline isolates the advisors
|
||||
// CELL tooltip, the one this test actually exercises.
|
||||
const HEADER_TOOLTIP_COUNT = 11
|
||||
const HEADER_TOOLTIP_COUNT = 10
|
||||
const baseMunicipality = {
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
|||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import { formatMunicipalityConcentrationHint } from '@/utilities/municipality/municipalityLabels'
|
||||
import {
|
||||
applyMunicipalityKindFilter,
|
||||
buildMunicipalityFilterHref,
|
||||
buildMunicipalityFilterOptionHref,
|
||||
formatMunicipalityActiveFiltersSummary,
|
||||
|
|
@ -256,13 +255,13 @@ describe('municipality list header filters (B16+)', () => {
|
|||
page: '3',
|
||||
sort: 'votos',
|
||||
dir: 'asc',
|
||||
kind: 'zona',
|
||||
priority: 'alta',
|
||||
})
|
||||
const withRegion = toggleMunicipalityMultiFilterValue(state, 'region', 'Irecê')
|
||||
expect(withRegion.regions).toEqual(['Irecê'])
|
||||
expect(withRegion.page).toBe(1)
|
||||
expect(buildMunicipalityFilterHref(withRegion)).toBe(
|
||||
'/campanha/municipios?region=Irec%C3%AA&kind=zona&sort=votos&dir=asc',
|
||||
'/campanha/municipios?region=Irec%C3%AA&priority=alta&sort=votos&dir=asc',
|
||||
)
|
||||
|
||||
const withTwo = toggleMunicipalityMultiFilterValue(withRegion, 'region', 'Recôncavo')
|
||||
|
|
@ -329,7 +328,6 @@ describe('municipality list header filters (B16+)', () => {
|
|||
expect(
|
||||
toggleMunicipalityMultiFilterValue(state, 'region', 'NotATerritory').regions,
|
||||
).toBeUndefined()
|
||||
expect(applyMunicipalityKindFilter(state, 'bairro').kind).toBeUndefined()
|
||||
expect(
|
||||
toggleMunicipalityExclusiveFilterValue(state, 'coverage', 'maybe').coverage,
|
||||
).toBeUndefined()
|
||||
|
|
|
|||
Loading…
Reference in a new issue