Merge branch 'cursor/f95cf56d'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 09:39:42 -03:00
commit e712397241
20 changed files with 591 additions and 344 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,36 @@
# Escala e DRY pós-B33 (ordenação e filtros de dobradinhas)
Status: rascunho
Atualizado em: 2026-07-26
Status: entregue (2026-07-27)
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B33+**, fill-in de engenharia)
Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de URL nem markup visível)
Appetite: ~0,5 dia eng; fase única, sem migration, sem collection, sem `Consent`
Responsável: —
## Como ficou (2026-07-27)
Entregue como planejado no essencial — um hook em `shared/`, três call sites, JSX intocado — com quatro correções que a auditoria pré-implementação impôs sobre o texto abaixo. Ele fica preservado como registro do raciocínio; o que vale é esta seção.
1. **O racional da "Decisão travada" caiu: um único `toHref` cobre os três.** O plano supunha que os domínios tinham assinaturas de canonicalização diferentes e que unificá-las custaria mudar `buildXListHref`/`parseXListParams`. Falso: `buildStateDeputyListHref`, `buildTerritoryListHref` e `buildMunicipalityListHref` **já canonicalizam internamente** (`parseXListParams(xListStateToRawParams(...))` dentro de `buildXListSearchParams` / `serializeTerritoryListSearchParams`), e `buildMunicipalityFiltersKey` é literalmente `buildMunicipalityListSearchParams(state).toString()` — a query string canônica, equivalente a comparar hrefs sob o mesmo basePath. Logo o `parse(...)` explícito em dobradinhas e territórios era **trabalho redundante**, não diferença de domínio, e o hook recebe um só callback `toHref`, não o par `toCanonical`/`toHref`. A decisão de fundo (o hook recebe callback e nunca importa os módulos de URL por domínio) permanece intacta.
2. **Achado novo — divergência de comportamento, corrigida.** Em `/campanha/municipios` cada filtro/sort carregava a busca digitada e não commitada; em dobradinhas e territórios `navigateTo` chamava `clearDebounce()` primeiro, então tocar um filtro no meio da digitação **descartava** a busca pendente enquanto o input seguia exibindo o texto. O hook unifica na semântica de municípios via `navigateWithSearch`, que é por isso a **terceira** função exposta — a "Questão em aberto" fecha com três nomes (`navigateTo`, `navigateWithSearch`, `scheduleSearch`), não dois — dos quais só `navigateWithSearch` sobreviveu à segunda rodada de `/simplify` (ponto 10a). `clearDebounce` não é exposto: `navigateTo` limpa antes de qualquer guarda, então os três "Limpar" só precisam de `setSearch('') + navigateTo(clearXListFilters(state))` — o que também eliminou o `router.replace` solto de municípios.
3. **Export orfanado, deletado.** `shouldUpdateMunicipalitySearchUrl` (`municipalityListUrl.ts`) era `normalizedText(input) !== currentQ` com dois consumidores: `MunicipalityFilters` e o próprio spec. Com a guarda genérica dentro do hook, morreu junto com seu `describe` em `municipalityList.unit.spec.ts` — knip trata `exports` como ERROR no CI.
4. **Rede de segurança onde não havia nenhuma:** `tests/unit/campaignListFilterNavigation.unit.spec.ts` renderiza os três filtros em jsdom com `next/navigation` mockado e timers falsos (`toFake: ['setTimeout','clearTimeout']` — o scheduler do React precisa de microtasks reais). Cinco casos × três listas na primeira volta (oito depois do ponto 9): debounce de 1000ms num único `replace` canônico, reinício da janela ao continuar digitando, dedup de no-op, cancelamento no unmount e o carregamento do rascunho de busca. Escrito antes da migração, o quinto caso nasceu **vermelho em dobradinhas e territórios** e verde em municípios, que é exatamente o achado 2.
5. **A extração tinha reimplantado o fallback de transition que já existia em quatro lugares (`/simplify`, reuse review).** O bloco `useCampaignListPending() ?? useTransition()` estava copiado em `CampaignSearchForm`, `CampaignFilterChips`, `SupporterFilters`, `ActivityFilters` e `CampaignTransitionAnchor` — o hook novo foi a sexta cópia, num item cuja tese é justamente extrair um padrão de 3 call sites. Virou `useCampaignListTransition()` em `CampaignListPending.tsx`, consumido pelos seis; `useCampaignListPending` deixou de ser exportado (o contexto agora se lê por um caminho só).
6. **`normalizedText(search) || state.q` também era a mesma expressão nos três** — o hook passou a devolvê-la como `draftQ`, o que tirou o último import de `campaignListUrl` dos três componentes e dá **um** lugar para resolver a assimetria que sobrou (o resumo cai no `q` commitado quando a caixa esvazia; `navigateWithSearch` limpa). Semântica preservada como estava.
7. **Uma correção de a11y que a comparação linha a linha revelou:** só `MunicipalityFilters` tinha `aria-live="polite"` no parágrafo **visível** de resumo, sobre um `md:block` — era a terceira região viva da mesma página (com o `sr-only` do próprio form e o de `CampaignListResults`), anunciando o resumo de filtros a cada tecla. Removida, junto com um `div` de um filho só herdado de quando a linha tinha dois botões. Isto **quebra** a promessa de "zero markup" do saldo abaixo, deliberadamente: era bug, não estilo. O `md:whitespace-normal` ficou (o resumo de municípios é o mais longo dos três).
8. **Bug latente que a extração passou a possuir, corrigido (`/simplify`, code-quality review).** O `setTimeout` do debounce capturava o `state` do render que o agendou, e os controles que navegam **de fora** da casca (`CampaignSortableHead`, paginação do `CampaignListFooter`, a âncora "Limpar" do estado vazio) mudam `state` sem tocar no timer. Em territórios e dobradinhas isso significava: digite, clique num sort dentro de 1s, e um segundo depois a navegação pendente **revertia o sort**. Municípios escapava por acidente — o `key=` da página remonta o componente e o cleanup mata o timer. Agora `stateRef` dá ao timer e à guarda de dedup o estado commitado mais recente, então a navegação pendente **soma-se** ao sort em vez de atropelá-lo, nos três. O caso novo do spec (`não reverte uma navegação feita de fora da casca`) foi verificado vermelho nas três listas com o `stateRef` revertido.
9. **Outros achados da mesma revisão, todos baratos:** `navigateWithSearch` passava a busca crua e dependia de um invariante que só existia num JSDoc — agora normaliza no próprio hook (href idêntico, invariante local); `{ ...next, page: 1 }` em dois call sites era morto (`buildXListHref(state, page)` já ignora `state.page` quando recebe o argumento) e saiu junto com um terceiro em `MunicipalityFilters`; a guarda `normalizedText(value) === state.q` de `scheduleSearch` era uma **segunda** fonte de verdade para "isto muda a URL?" — exatamente o que o `shouldUpdateMunicipalitySearchUrl` deletado era — e foi removida, deixando a dedup só em `navigateTo`; e o spec passou a montar dentro de `CampaignListPendingBoundary`, porque as três páginas renderizam sob uma e o fallback local de `useTransition` é o ramo que **nunca** roda em produção (antes, todas as asserções corriam pelo ramo morto). Somaram-se casos para "Limpar", para esvaziar uma busca commitada e para provar que o timer morreu após o sort: 5 → 8 casos × 3 listas.
10. **Segunda rodada de `/simplify` (reuse + performance), depois do rebase.** Cinco correções, todas de reuso ou de superfície: (a) `setSearch` + `scheduleSearch` eram sempre chamados em par nos três `onChange` (em duas grafias diferentes) — viraram um `onSearchChange(value)`, o que tirou **os dois** da superfície exportada; com `navigateTo` já privado atrás de `clearSearchAndNavigate`, o hook devolve 6 nomes em vez de 8, e não existe mais forma de ligar metade do par. (b) `MunicipalityFilters` reimplementava inline o `buildMunicipalityFilterHref` que `municipalityListFilters.ts` **já exporta** com quatro consumidores; passou a recebê-lo, e o pin de página 1 volta a morar num lugar por domínio. (c) O mesmo helper faltava em dobradinhas, onde `buildStateDeputyListHref(next, 1)` estava escrito à mão em quatro sítios — nasceu `buildStateDeputyFilterHref`, e a assimetria de aridade do prop `toHref` (dois unários e uma lambda) desapareceu. (d) Só `MunicipalityFilters` **não** mostrava o resumo de filtros ativos no bloco `md:hidden`: no telemóvel, onde esse bloco é o ponto todo, o utilizador de municípios não tinha indicação nenhuma de quais filtros estavam ligados. Adicionado o parágrafo que os outros dois já tinham. (e) A normalização que o ponto 9 tinha metido dentro de `navigateWithSearch` foi **revertida**: `scheduleSearch` sempre entregou a tecla crua ao `toHref`, então normalizar num caminho e não no outro era a segunda política que o ponto 9 dizia estar a remover — agora os dois entregam cru e o serializador decide, documentado no cabeçalho do hook. Fora do saldo: `stripTrailingSlash` (`utilities/seo.ts`) era um export sem consumidor **pré-existente** em `main` que fazia o knip do CI falhar; deixou de ser exportado.
Duas revisões da mesma rodada foram deferidas com gatilho em vez de aplicadas, e estão registadas no roadmap: extrair o `<Field>` de "Ordenar" (idêntico nos três, já com deriva de altura do select) e o par `serialize`/`parseXSortValue` triplicado verbatim nos três módulos de URL. A revisão de performance mediu a entrega como neutra (mesmo número de `router.replace`, mesma cadência de RSC) e o seu único achado real é anterior a este item: `/campanha/municipios` embarca ~10 kB gzip do catálogo no cliente por causa de um `isMunicipalitySlug`, também no roadmap.
Saldo medido no fim (incluindo as duas rodadas de `/simplify`): **278 linhas removidas contra 48 adicionadas nos três filtros**, 325 contra 228 em todo o `src` — e as 138 linhas do hook novo (metade comentário) estão dentro desse "adicionadas", ou seja ~97 linhas líquidas a menos, sem nenhuma mudança de contrato de URL. O markup só se moveu no ponto 7. `CampaignSearchForm` (lideranças/assessores/organizações) segue fora de escopo, mas **pelo motivo durável, não pelo que este plano registrava antes**: não é "sem debounce" (adicionar um debounce reabriria a questão) — é que ele monta o href como `${basePath}?q=…`, **descartando todos os outros params**, porque esses três domínios não têm módulo de URL nem `parseXListParams`. Entregá-lo a um hook cujo contrato é "canonicalizar, comparar, deduplicar" tornaria o contrato falso naquele call site. B29 provavelmente o substitui por um `LeadershipFilters` real.
**Verificação:** gate do AGENTS.md verde e bare (`tsc --noEmit`, `pnpm lint`, `format:check`, `knip`, `check:cycles`, 562 unit + 413 int, `pnpm build` local); Aikido sem findings. O e2e não pôde ser usado como veredito nesta sessão: a máquina estava com load average ~58 (dois outros worktrees com dev server ativo), e `campaignMunicipalities.e2e.spec.ts` falhou de forma instável tanto na árvore nova quanto na árvore limpa em stash — o flake pré-existente registrado no AGENTS.md, amplificado por timeout. `campaignTerritories.e2e.spec.ts` passou em todas as rodadas, e nenhum teste de municípios exercita o formulário de filtros (eles navegam direto por `?q=`), então o e2e não cobria esta superfície de qualquer forma. É o ponto fraco conhecido da entrega: a hidratação real segue sem pin.
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A) — o lote é custo de manutenção de um hook de navegação já entregue três vezes; nenhuma métrica, série ou escala nova.
@ -71,7 +95,15 @@ Componentes:
## Adiado com gatilho
Nenhum neste item.
Achados do `/simplify` (2026-07-27) que são maiores que limpeza:
- **Shell de `<form>` compartilhado — a evidência registrada em "Decisões travadas" é mensuravelmente falsa e fica corrigida aqui.** Normalizando os substantivos de domínio, `TerritoryFilters` e `StateDeputyFilters` são **110 de 147 linhas byte-idênticas**, e o par `<form role="search">` + parágrafo `sr-only` (13 linhas) é byte-idêntico nos **três**. O plano tratou "o meio diverge" como prova de "a casca diverge", e são afirmações diferentes: a casca sai por `children` (o idioma que `CampaignListResults`/`LeaderContactsPanel`/`mapSlot` já usam), sem o render-prop que foi rejeitado. **Gatilho:** o 4º filtro de lista (o `LeadershipFilters` de B29). Quem reabrir não deve reusar o argumento antigo.
- ~~**Ressincronizar a busca com `state.q`**~~ — **decidido e feito na 2ª rodada de `/simplify`**, pelo terceiro caminho: o hook segue a URL quando o `q` commitado muda **e não há debounce pendente** (`committedQ`), então Back/Forward e a âncora "Limpar busca e filtros" atualizam a caixa, mas o texto de quem está a digitar nunca é atropelado — que era o custo com que os dois revisores se cobravam mutuamente. Com isso o `key={buildMunicipalityFiltersKey(state)}` de `/campanha/municipios` **saiu** (era a única razão para as três páginas divergirem, e remontar a cada navegação committed matava o timer, tornando a guarda de dedup do ponto 8 código morto justamente num dos três call sites); `buildMunicipalityFiltersKey` orfanou e foi deletado. Sobra a política de montagem de `/campanha/apoiadores` e `/campanha/atividades`, que continuam com `key=` — não são consumidores do hook, então nada quebra; unificar quando adotarem a navegação compartilhada.
- **`<Field>` de "Ordenar" extraído (`CampaignListSortSelect`).** Os três blocos de 21 linhas diferem só nos identificadores de domínio — e **já divergiram**: `MunicipalityFilters` usa `className="min-h-11 w-full"` onde os outros dois usam `"w-full [&_select]:h-11"`, duas alturas para o mesmo controlo na mesma família de listas. O precedente de extração está na mesma pasta e tem os mesmos três call sites (`CampaignMobileMultiFilterField`). Não foi feito aqui por não haver decisão de qual altura é a correta — é escolha de design, não de código. **Gatilho:** a decisão de altura, ou o shell acima (que engole este bloco).
- **`serializeXSortValue` / `parseXSortValue` triplicados verbatim** (`municipalityListUrl.ts`, `territoryListUrl.ts`, `stateDeputyListUrl.ts`): `` `${key}|${dir}` `` e um `split('|')` com dois `Set.has`. `campaignListUrl.ts` é a casa desse tipo de parsing partilhado (já tem `normalizedText`, `allParamValues`, `parseExhaustiveEnumParam`) e não tem nenhum helper de sort. Genérico lá remove as três cópias sem tocar em contrato de URL. **Gatilho:** o 4º módulo de URL com sort (B29), ou o primeiro bug de deriva entre as cópias.
- **`SupporterFilters` / `ActivityFilters` adotarem o hook.** Ganharam `useCampaignListTransition`, mas não a navegação. **A razão registada aqui antes era factualmente falsa** e fica corrigida (reuse review, 2ª rodada): não é "montam o href sem passo de parse" — `buildSupporterListSearchParams` e `buildActivityListSearchParams` chamam `parseXListParams(...)` na primeira linha, exatamente a propriedade que justificou colapsar os outros três num só `toHref`. O impedimento real é o `valuesRef`/`useOptimistic` espelhando todos os filtros, para o qual o hook não tem equivalente; e migrar lhes **daria** dedup de no-op, mudança de comportamento em duas superfícies vivas. **Gatilho:** quando um deles precisar de dedup ou de busca com debounce. Independente disso, os dois reconstroem o href à mão a partir de `usePathname()` embora `buildSupporterListHref`/`buildActivityListHref` já existam e estejam pinados — dívida separada, no roadmap.
- **`/campanha/municipios` embarca ~10 kB gzip do catálogo dos 435 municípios no bundle do cliente** — achado da performance review, **anterior a este item** e a única coisa que ela encontrou: um `isMunicipalitySlug` num componente cliente arrasta `municipalityCatalog` inteiro, exatamente a violação de fronteira que o B14 pagou 21 kB para aprender ("client components não importam módulos de dados do servidor para valores"). Fora de escopo aqui porque a correção é no lado que consome o predicado, não no hook. **Gatilho:** próximo trabalho de bundle em `/campanha/municipios` (o B41/B42 tocam a mesma rota).
- **Terceiro idioma de mock de router nos testes.** `campaignListFilterNavigation.unit.spec.ts` usa `vi.hoisted` + `vi.mock('next/navigation')` com spread do módulo real; a casa já tem `stub<AppRouterInstance>` dentro de `AppRouterContext.Provider` (`campaignComponents.unit.spec.ts`), que dá as mesmas asserções sem mock de módulo. Não convertido por ser troca de harness num spec de 10 casos × 3 listas que está verde. **Gatilho:** o 4º spec que precise de router, ou o primeiro sintoma de deriva do spread.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -40,6 +40,7 @@ import {
type StateDeputyRowViewModel,
} from '@/utilities/stateDeputyData'
import {
buildStateDeputyFilterHref,
clearStateDeputyListFilters,
type StateDeputyFilterOption,
} from '@/utilities/stateDeputyListFilters'
@ -71,7 +72,7 @@ const StateDeputyListEmptyState = ({ state }: { state: StateDeputyListState }) =
<EmptyContent>
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
<CampaignTransitionAnchor
href={buildStateDeputyListHref(clearStateDeputyListFilters(state), 1)}
href={buildStateDeputyFilterHref(clearStateDeputyListFilters(state))}
replace
scroll={false}
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}

View file

@ -26,7 +26,6 @@ import {
buildMunicipalityListVisitLabel,
} from '@/utilities/municipalityListFilters'
import {
buildMunicipalityFiltersKey,
buildMunicipalityListHref,
resolveMunicipalityListUrl,
} from '@/utilities/municipalityListUrl'
@ -97,7 +96,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
const filters = (
<MunicipalityFilters
key={buildMunicipalityFiltersKey(state)}
state={state}
showStaffFilters={isStaffView}
regionFilterOptions={columnFilterOptions.region}

View file

@ -1,10 +1,10 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useRef, useState, useTransition } from 'react'
import { useRef, useState } from 'react'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
@ -82,11 +82,7 @@ export const ActivityFilters = ({
const valuesRef = useRef(initialValues)
const [values, setValues] = useState(initialValues)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
// Prefer the page-level boundary so the results region dims together.
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const { isPending, startTransition } = useCampaignListTransition()
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues

View file

@ -1,17 +1,14 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { normalizedText } from '@/utilities/campaignListUrl'
import {
applyMunicipalityKindFilter,
buildMunicipalityFilterHref,
clearMunicipalityListFilters,
formatMunicipalityActiveFiltersSummary,
getMunicipalityFilterDefinition,
@ -22,18 +19,13 @@ import {
type MunicipalityFilterOption,
} from '@/utilities/municipalityListFilters'
import {
buildMunicipalityFiltersKey,
buildMunicipalityListHref,
municipalityListSortOptions,
parseMunicipalitySortValue,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
const SEARCH_DEBOUNCE_MS = 1000
type MunicipalityFiltersProps = {
state: MunicipalityListState
showStaffFilters: boolean
@ -49,71 +41,15 @@ export const MunicipalityFilters = ({
regionFilterOptions,
advisorFilterOptions,
}: MunicipalityFiltersProps) => {
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
// Prefer the page-level boundary so the results region dims together.
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildMunicipalityFilterHref })
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const draftQ = normalizedText(search) || state.q
const activeFiltersSummary = formatMunicipalityActiveFiltersSummary({
...state,
q: draftQ,
})
const hasActiveFilters = Boolean(activeFiltersSummary)
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
},
[],
)
const clearDebounce = () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
}
const navigateTo = (next: MunicipalityListState) => {
clearDebounce()
const canonical: MunicipalityListState = {
...next,
page: 1,
q: normalizedText(next.q),
}
if (
buildMunicipalityFiltersKey(canonical) === buildMunicipalityFiltersKey({ ...state, page: 1 })
)
return
startTransition(() => {
router.replace(buildMunicipalityListHref(canonical, 1), { scroll: false })
})
}
const commitNavigation = (patch: Partial<MunicipalityListState>) => {
navigateTo({
...state,
...patch,
q: normalizedText(patch.q !== undefined ? patch.q : search),
})
}
const scheduleSearchNavigation = (value: string) => {
clearDebounce()
if (!shouldUpdateMunicipalitySearchUrl(value, state.q)) return
debounceRef.current = setTimeout(() => {
debounceRef.current = null
commitNavigation({ q: value })
}, SEARCH_DEBOUNCE_MS)
}
const mobileFilterDefinitions = municipalityFilterDefinitions.filter((definition) => {
if (definition.staffOnly && !showStaffFilters) return false
// Header popovers own multi/name filters; mobile keeps exclusive selects.
@ -128,57 +64,38 @@ export const MunicipalityFilters = ({
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
commitNavigation({ q: search })
navigateWithSearch(state)
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="municipality-search"
label="Buscar município"
placeholder="Buscar por município ou zona…"
value={search}
onChange={(event) => {
const value = event.target.value
setSearch(value)
scheduleSearchNavigation(value)
}}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeFiltersSummary ? (
<p
className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2 md:whitespace-normal"
aria-live="polite"
>
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2 md:whitespace-normal">
{activeFiltersSummary}
</p>
) : null}
{hasActiveFilters ? (
<div className="flex shrink-0 gap-2 md:self-end">
<Button
type="button"
variant="ghost"
className="min-h-11"
onClick={() => {
clearDebounce()
setSearch('')
startTransition(() => {
router.replace(
buildMunicipalityListHref(clearMunicipalityListFilters(state), 1),
{ scroll: false },
)
})
}}
>
Limpar
</Button>
</div>
<Button
type="button"
variant="ghost"
className="min-h-11 shrink-0 md:self-end"
onClick={() => clearSearchAndNavigate(clearMunicipalityListFilters(state))}
>
Limpar
</Button>
) : null}
</div>
<div className="flex flex-col gap-3 md:hidden">
{activeFiltersSummary ? (
<p className="text-sm text-muted-foreground">{activeFiltersSummary}</p>
) : null}
{showStaffFilters ? (
<Field>
<FieldLabel htmlFor="municipality-filter-priority">Prioridade</FieldLabel>
@ -186,10 +103,9 @@ export const MunicipalityFilters = ({
id="municipality-filter-priority"
value={state.priority ?? ''}
onChange={(event) => {
navigateTo({
navigateWithSearch({
...state,
priority: event.target.value === 'alta' ? 'alta' : undefined,
q: normalizedText(search),
})
}}
className="min-h-11 w-full"
@ -207,13 +123,7 @@ export const MunicipalityFilters = ({
options={regionFilterOptions}
selected={state.regions ?? []}
onToggle={(value) =>
navigateTo(
toggleMunicipalityMultiFilterValue(
{ ...state, q: normalizedText(search) },
'region',
value,
),
)
navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'region', value))
}
/>
) : null}
@ -232,17 +142,16 @@ export const MunicipalityFilters = ({
value={value}
onChange={(event) => {
const selected = event.target.value
const withSearch = { ...state, q: normalizedText(search) }
if (definition.selection === 'toggle') {
navigateTo(
navigateWithSearch(
selected
? toggleMunicipalityExclusiveFilterValue(withSearch, 'coverage', selected)
: { ...withSearch, coverage: undefined, page: 1 },
? toggleMunicipalityExclusiveFilterValue(state, 'coverage', selected)
: { ...state, coverage: undefined },
)
return
}
if (definition.param === 'kind') {
navigateTo(applyMunicipalityKindFilter(withSearch, selected))
navigateWithSearch(applyMunicipalityKindFilter(state, selected))
}
}}
className="min-h-11 w-full"
@ -265,13 +174,7 @@ export const MunicipalityFilters = ({
options={getMunicipalityFilterDefinition('trend').options ?? []}
selected={state.trends ?? []}
onToggle={(value) =>
navigateTo(
toggleMunicipalityMultiFilterValue(
{ ...state, q: normalizedText(search) },
'trend',
value,
),
)
navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'trend', value))
}
/>
) : null}
@ -283,13 +186,7 @@ export const MunicipalityFilters = ({
options={getMunicipalityFilterDefinition('class').options ?? []}
selected={state.classes ?? []}
onToggle={(value) =>
navigateTo(
toggleMunicipalityMultiFilterValue(
{ ...state, q: normalizedText(search) },
'class',
value,
),
)
navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'class', value))
}
/>
) : null}
@ -301,13 +198,7 @@ export const MunicipalityFilters = ({
options={advisorFilterOptions}
selected={(state.advisors ?? []).map(String)}
onToggle={(value) =>
navigateTo(
toggleMunicipalityMultiFilterValue(
{ ...state, q: normalizedText(search) },
'advisor',
value,
),
)
navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'advisor', value))
}
/>
) : null}
@ -318,7 +209,7 @@ export const MunicipalityFilters = ({
value={serializeMunicipalitySortValue(activeSort, activeDir)}
onChange={(event) => {
const parsed = parseMunicipalitySortValue(event.target.value)
if (parsed) commitNavigation({ sort: parsed.key, dir: parsed.dir })
if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir })
}}
className="min-h-11 w-full"
>

View file

@ -1,15 +1,11 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { normalizedText } from '@/utilities/campaignListUrl'
import {
clearTerritoryListFilters,
formatTerritoryActiveFiltersSummary,
@ -19,17 +15,13 @@ import {
} from '@/utilities/territoryListFilters'
import {
buildTerritoryListHref,
parseTerritoryListParams,
parseTerritorySortValue,
resolveTerritoryListSort,
serializeTerritorySortValue,
territoryListSortOptions,
territoryListStateToRawParams,
type TerritoryListState,
} from '@/utilities/territoryListUrl'
const SEARCH_DEBOUNCE_MS = 1000
export const TerritoryFilters = ({
state,
regionOptions,
@ -37,51 +29,10 @@ export const TerritoryFilters = ({
state: TerritoryListState
regionOptions: TerritoryFilterOption[]
}) => {
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildTerritoryListHref })
const { sort, dir } = resolveTerritoryListSort(state)
const activeSummary = formatTerritoryActiveFiltersSummary({
...state,
q: normalizedText(search) || state.q,
})
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
},
[],
)
const clearDebounce = () => {
if (!debounceRef.current) return
clearTimeout(debounceRef.current)
debounceRef.current = null
}
const navigateTo = (next: TerritoryListState) => {
clearDebounce()
const canonical = parseTerritoryListParams(
territoryListStateToRawParams({ ...next, q: normalizedText(next.q) }),
)
if (buildTerritoryListHref(canonical) === buildTerritoryListHref(state)) return
startTransition(() => {
router.replace(buildTerritoryListHref(canonical), { scroll: false })
})
}
const scheduleSearch = (value: string) => {
clearDebounce()
if (normalizedText(value) === state.q) return
debounceRef.current = setTimeout(() => {
debounceRef.current = null
navigateTo({ ...state, q: value })
}, SEARCH_DEBOUNCE_MS)
}
const activeSummary = formatTerritoryActiveFiltersSummary({ ...state, q: draftQ })
return (
<form
@ -91,23 +42,16 @@ export const TerritoryFilters = ({
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
navigateTo({ ...state, q: search })
navigateWithSearch(state)
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="territory-search"
label="Buscar território"
placeholder="Buscar por território…"
value={search}
onChange={(event) => {
setSearch(event.target.value)
scheduleSearch(event.target.value)
}}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
@ -119,11 +63,7 @@ export const TerritoryFilters = ({
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => {
clearDebounce()
setSearch('')
navigateTo(clearTerritoryListFilters(state))
}}
onClick={() => clearSearchAndNavigate(clearTerritoryListFilters(state))}
>
Limpar
</Button>
@ -138,7 +78,7 @@ export const TerritoryFilters = ({
emptyLabel="Todos"
options={regionOptions}
selected={state.regions ?? []}
onToggle={(value) => navigateTo(toggleTerritoryRegionFilter(state, value))}
onToggle={(value) => navigateWithSearch(toggleTerritoryRegionFilter(state, value))}
/>
<Field>
@ -147,7 +87,7 @@ export const TerritoryFilters = ({
id="territory-filter-coverage"
value={state.coverage ?? ''}
onChange={(event) =>
navigateTo({
navigateWithSearch({
...state,
coverage:
event.target.value === 'com_assessor' || event.target.value === 'sem_assessor'
@ -173,7 +113,7 @@ export const TerritoryFilters = ({
value={serializeTerritorySortValue(sort, dir)}
onChange={(event) => {
const parsed = parseTerritorySortValue(event.target.value)
if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir })
if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir })
}}
className="w-full [&_select]:h-11"
>

View file

@ -2,9 +2,9 @@
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition, type MouseEvent } from 'react'
import { useState, type MouseEvent } from 'react'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { Button } from '@/components/ui/button'
export type CampaignFilterChip = {
@ -26,10 +26,7 @@ export const CampaignFilterChips = ({
chips: CampaignFilterChip[]
}) => {
const router = useRouter()
const shared = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = shared?.isPending ?? isLocalPending
const startTransition = shared?.startTransition ?? startLocalTransition
const { isPending, startTransition } = useCampaignListTransition()
const [optimisticHref, setOptimisticHref] = useState<string | null>(null)
const activeHref =

View file

@ -37,9 +37,20 @@ export const CampaignListPendingBoundary = ({ children }: { children: ReactNode
)
}
/** Filter controls prefer the shared transition so the results dim with them. */
export const useCampaignListPending = (): CampaignListPendingValue | null =>
useContext(CampaignListPendingContext)
/**
* The transition a navigating control should drive: the page-level boundary
* when there is one — so the results region dims with the control — and a local
* one otherwise, for controls rendered outside a boundary.
*/
export const useCampaignListTransition = (): CampaignListPendingValue => {
const shared = useContext(CampaignListPendingContext)
const [isLocalPending, startLocalTransition] = useTransition()
return {
isPending: shared?.isPending ?? isLocalPending,
startTransition: shared?.startTransition ?? startLocalTransition,
}
}
/** Dims while a shared list navigation is pending; safe to render from RSC. */
export const CampaignTransitionAnchor = forwardRef<
@ -61,9 +72,7 @@ export const CampaignTransitionAnchor = forwardRef<
ref,
) {
const router = useRouter()
const shared = useContext(CampaignListPendingContext)
const [, startLocalTransition] = useTransition()
const startTransition = shared?.startTransition ?? startLocalTransition
const { startTransition } = useCampaignListTransition()
return (
<a

View file

@ -1,9 +1,9 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { useState } from 'react'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { Button } from '@/components/ui/button'
/**
@ -24,10 +24,7 @@ export const CampaignSearchForm = ({
basePath: string
}) => {
const router = useRouter()
const shared = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = shared?.isPending ?? isLocalPending
const startTransition = shared?.startTransition ?? startLocalTransition
const { isPending, startTransition } = useCampaignListTransition()
const [query, setQuery] = useState(initialQuery)
const hrefForQuery = (value: string) =>

View file

@ -0,0 +1,138 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { normalizedText } from '@/utilities/campaignListUrl'
/**
* Long for search-as-you-type on purpose: every commit is an RSC round trip
* over a mobile connection, and the field team types município names slowly.
*/
export const SEARCH_DEBOUNCE_MS = 1000
/**
* Navigation scaffold shared by the list filter shells (municípios,
* territórios, dobradinhas): debounced search, no-op deduplication and the
* pending transition the results region dims with.
*
* The hook never decides what is canonical — `toHref` comes from the domain's
* own URL module (`buildXListHref`, which already canonicalizes), so the three
* URL contracts stay independent instead of collapsing into one DSL. Raw
* keystrokes are handed to `toHref` untrimmed for the same reason: trimming is
* the serializer's job, and doing it here too would be a second policy.
*/
export const useCampaignListFilterNavigation = <State extends { q?: string }>({
state,
toHref,
}: {
/** Canonical state parsed from the URL on the server. */
state: State
/** Domain serializer; must canonicalize and pin the page reset it wants. */
toHref: (next: State) => string
}) => {
const router = useRouter()
const { isPending, startTransition } = useCampaignListTransition()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [search, setSearch] = useState(state.q ?? '')
/**
* Follow the URL when the committed query changes under us — back/forward and
* the empty state's "Limpar busca e filtros" navigate from outside this shell.
* Without this the box would keep showing a query the URL no longer has, and
* the next filter touch would put it back (`navigateWithSearch` carries the
* box). A pending debounce wins: that text is the user's, not the URL's.
*/
const [committedQ, setCommittedQ] = useState(state.q)
if (state.q !== committedQ) {
setCommittedQ(state.q)
if (!debounceRef.current) setSearch(state.q ?? '')
}
/**
* The controls that navigate from *outside* this shell — sortable heads,
* pagination — change `state` without touching the pending timer. Reading the
* committed state through a ref keeps a timer scheduled before such a
* navigation from reverting it a second later. Written in an effect, not
* during render: an interrupted transition render is abandoned before commit,
* and the timer must not resolve against state that never landed.
*/
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
})
const clearDebounce = () => {
if (!debounceRef.current) return
clearTimeout(debounceRef.current)
debounceRef.current = null
}
useEffect(() => clearDebounce, [])
/**
* Cancels any pending search, then navigates — unless `next` serializes to
* what is already committed. Both sides pin the same page, so the guard asks
* "do the filters and sort change?", not "is this the current URL?": a
* re-submitted identical search on page 3 leaves the user on page 3.
*/
const navigateTo = (next: State) => {
clearDebounce()
const nextHref = toHref(next)
if (nextHref === toHref(stateRef.current)) return
startTransition(() => {
router.replace(nextHref, { scroll: false })
})
}
/**
* Navigates carrying the search the user has typed but not yet committed —
* otherwise touching a filter mid-typing would drop it while the input keeps
* showing the text.
*/
const navigateWithSearch = (next: State) => {
navigateTo({ ...next, q: search })
}
/**
* The "Limpar" buttons: empties the box and drops `q` in the same gesture.
* Exists so no caller has to know that `navigateWithSearch` would re-commit
* the search being cleared, because `setSearch('')` has not flushed yet.
*/
const clearSearchAndNavigate = (next: State) => {
setSearch('')
navigateTo(next)
}
/**
* The search input's only handler: mirrors the keystroke and (re)arms the
* debounce in one call, so no caller can wire up half of it.
*
* Typing back to the committed value still arms a timer — it cancels the
* pending navigation rather than merely skipping a new one, because the guard
* lives in `navigateTo` and the timer is free to resolve to a no-op.
*/
const onSearchChange = (value: string) => {
setSearch(value)
clearDebounce()
debounceRef.current = setTimeout(() => {
navigateTo({ ...stateRef.current, q: value })
}, SEARCH_DEBOUNCE_MS)
}
return {
search,
onSearchChange,
/**
* What the active-filters summary should describe. The draft wins while the
* box is non-empty; an emptied box falls back to the committed `q` so the
* summary does not flicker off during the debounce, before the results it
* describes have actually changed.
*/
draftQ: normalizedText(search) || state.q,
isPending,
navigateWithSearch,
clearSearchAndNavigate,
}
}

View file

@ -1,16 +1,13 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { normalizedText } from '@/utilities/campaignListUrl'
import {
buildStateDeputyFilterHref,
buildStateDeputyPartyOptions,
clearStateDeputyListFilters,
formatStateDeputyActiveFiltersSummary,
@ -18,18 +15,13 @@ import {
type StateDeputyFilterOption,
} from '@/utilities/stateDeputyListFilters'
import {
buildStateDeputyListHref,
parseStateDeputyListParams,
parseStateDeputySortValue,
resolveStateDeputyListSort,
serializeStateDeputySortValue,
stateDeputyListSortOptions,
stateDeputyListStateToRawParams,
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
const SEARCH_DEBOUNCE_MS = 1000
export const StateDeputyFilters = ({
state,
partyOptions,
@ -41,53 +33,11 @@ export const StateDeputyFilters = ({
/** Whether at least one facet-matching row has no party — gates the "Sem partido" option. */
hasNoParty: boolean
}) => {
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildStateDeputyFilterHref })
const { sort, dir } = resolveStateDeputyListSort(state)
const mobilePartyOptions = buildStateDeputyPartyOptions(partyOptions, hasNoParty)
const activeSummary = formatStateDeputyActiveFiltersSummary({
...state,
q: normalizedText(search) || state.q,
})
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
},
[],
)
const clearDebounce = () => {
if (!debounceRef.current) return
clearTimeout(debounceRef.current)
debounceRef.current = null
}
const navigateTo = (next: StateDeputyListState) => {
clearDebounce()
const canonical = parseStateDeputyListParams(
stateDeputyListStateToRawParams({ ...next, q: normalizedText(next.q), page: 1 }, 1),
)
const nextHref = buildStateDeputyListHref(canonical, 1)
if (nextHref === buildStateDeputyListHref(state, 1)) return
startTransition(() => {
router.replace(nextHref, { scroll: false })
})
}
const scheduleSearch = (value: string) => {
clearDebounce()
if (normalizedText(value) === state.q) return
debounceRef.current = setTimeout(() => {
debounceRef.current = null
navigateTo({ ...state, q: value, page: 1 })
}, SEARCH_DEBOUNCE_MS)
}
const activeSummary = formatStateDeputyActiveFiltersSummary({ ...state, q: draftQ })
return (
<form
@ -97,23 +47,16 @@ export const StateDeputyFilters = ({
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
navigateTo({ ...state, q: search, page: 1 })
navigateWithSearch(state)
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="state-deputy-search"
label="Buscar dobradinha"
placeholder="Buscar por nome…"
value={search}
onChange={(event) => {
setSearch(event.target.value)
scheduleSearch(event.target.value)
}}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
@ -125,11 +68,7 @@ export const StateDeputyFilters = ({
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => {
clearDebounce()
setSearch('')
navigateTo(clearStateDeputyListFilters(state))
}}
onClick={() => clearSearchAndNavigate(clearStateDeputyListFilters(state))}
>
Limpar
</Button>
@ -144,7 +83,7 @@ export const StateDeputyFilters = ({
emptyLabel="Todos"
options={mobilePartyOptions}
selected={state.parties ?? []}
onToggle={(value) => navigateTo(toggleStateDeputyPartyFilter(state, value))}
onToggle={(value) => navigateWithSearch(toggleStateDeputyPartyFilter(state, value))}
/>
<Field>
@ -154,7 +93,7 @@ export const StateDeputyFilters = ({
value={serializeStateDeputySortValue(sort, dir)}
onChange={(event) => {
const parsed = parseStateDeputySortValue(event.target.value)
if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir, page: 1 })
if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir })
}}
className="w-full [&_select]:h-11"
>

View file

@ -7,13 +7,14 @@ import {
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
import {
buildStateDeputyFilterHref,
buildStateDeputyPartyOptions,
clearStateDeputyPartyFilter,
isStateDeputyPartyFilterActive,
toggleStateDeputyPartyFilter,
type StateDeputyFilterOption,
} from '@/utilities/stateDeputyListFilters'
import { buildStateDeputyListHref, type StateDeputyListState } from '@/utilities/stateDeputyListUrl'
import { type StateDeputyListState } from '@/utilities/stateDeputyListUrl'
export const StateDeputyHeaderFilter = ({
state,
@ -36,7 +37,7 @@ export const StateDeputyHeaderFilter = ({
return {
value: option.value,
label: option.label,
href: buildStateDeputyListHref(next, 1),
href: buildStateDeputyFilterHref(next),
selected: Boolean(viewState.parties?.includes(option.value)),
checkbox: true,
onChoose: () => setOptimisticState(next),
@ -54,7 +55,7 @@ export const StateDeputyHeaderFilter = ({
clear={
active
? {
href: buildStateDeputyListHref(clearedState, 1),
href: buildStateDeputyFilterHref(clearedState),
onChoose: () => setOptimisticState(clearedState),
}
: undefined

View file

@ -1,9 +1,9 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useRef, useState, useTransition, type FormEvent } from 'react'
import { useRef, useState, type FormEvent } from 'react'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
@ -112,11 +112,7 @@ export const SupporterFilters = ({
const valuesRef = useRef(initialValues)
const [values, setValues] = useState(initialValues)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
// Prefer the page-level boundary so the results region dims together.
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const { isPending, startTransition } = useCampaignListTransition()
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues

View file

@ -361,9 +361,6 @@ const buildMunicipalityListSearchParams = (
parseMunicipalityListParams(municipalityListStateToRawParams(state, page)),
)
export const buildMunicipalityFiltersKey = (state: MunicipalityListState): string =>
buildMunicipalityListSearchParams(state).toString()
export const buildMunicipalityListHref = (state: MunicipalityListState, page: number): string =>
buildListHref(state, buildMunicipalityListSearchParams, '/campanha/municipios', page)
@ -435,11 +432,6 @@ export const parseMunicipalitySortValue = (
return { key: rawKey as MunicipalityListSortKey, dir: rawDir as MunicipalityListSortDirection }
}
export const shouldUpdateMunicipalitySearchUrl = (
input: string,
currentQ: string | undefined,
): boolean => normalizedText(input) !== currentQ
export const resolveMunicipalityListUrl = (
params: MunicipalityListSearchParams,
totalPages?: number,

View file

@ -1,5 +1,5 @@
/** Remove any trailing slash(es) so URLs concatenate predictably. */
export const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '')
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '')
/** Truncate `text` to `max` characters, appending an ellipsis when cut. */
export const truncate = (text: string, max: number): string =>

View file

@ -6,6 +6,7 @@
*/
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import {
buildStateDeputyListHref,
NO_PARTY_FILTER_VALUE,
parseStateDeputyListParams,
stateDeputyListStateToRawParams,
@ -64,6 +65,13 @@ export const clearStateDeputyListFilters = (state: StateDeputyListState): StateD
dir: state.dir,
})
/**
* Every filter change resets pagination, so the page-1 pin belongs here rather
* than at each call site — sibling of `buildMunicipalityFilterHref`.
*/
export const buildStateDeputyFilterHref = (next: StateDeputyListState): string =>
buildStateDeputyListHref(next, 1)
export const isStateDeputyPartyFilterActive = (state: StateDeputyListState): boolean =>
Boolean(state.parties?.length)

View file

@ -0,0 +1,325 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, createElement, type ReactElement } from 'react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const routerState = vi.hoisted(() => ({
replace: vi.fn(),
}))
vi.mock('next/navigation', async (importActual) => ({
...(await importActual()),
useRouter: () => ({ replace: routerState.replace }),
}))
import { MunicipalityFilters } from '@/components/campaign/municipality/MunicipalityFilters'
import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters'
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
import { SEARCH_DEBOUNCE_MS } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
/**
* The three list filter shells share one navigation scaffold
* (`useCampaignListFilterNavigation`). These cases are the contract that
* survives the extraction: debounce window, no-op dedup, unmount cleanup, and
* carrying the uncommitted search into a filter/sort navigation.
*/
type FilterCase = {
name: string
searchLabel: string
/** Canonical href for a committed `silva` search with no other state. */
hrefWithQuery: string
/** Canonical href for no state at all — what "Limpar" must navigate to. */
bareHref: string
/**
* A `key|dir` from the list's own sort options that is NOT its default —
* otherwise `sort=` drops out of the canonical href and the assertion fails
* for a reason unrelated to the hook.
*/
sortValue: string
element: (currentQuery?: string) => ReactElement
/** Same shell with `sortValue` already committed — an external navigation. */
elementWithSort: () => ReactElement
}
const filterCases: FilterCase[] = [
{
name: 'dobradinhas',
searchLabel: 'Buscar dobradinha',
hrefWithQuery: '/campanha/dobradinhas?q=silva',
bareHref: '/campanha/dobradinhas',
sortValue: 'party|asc',
element: (currentQuery) =>
createElement(StateDeputyFilters, {
state: { page: 1, ...(currentQuery ? { q: currentQuery } : {}) },
partyOptions: [],
hasNoParty: false,
}),
elementWithSort: () =>
createElement(StateDeputyFilters, {
state: { page: 1, sort: 'party', dir: 'asc' },
partyOptions: [],
hasNoParty: false,
}),
},
{
name: 'territórios',
searchLabel: 'Buscar território',
hrefWithQuery: '/campanha/territorios?q=silva',
bareHref: '/campanha/territorios',
sortValue: 'region|asc',
element: (currentQuery) =>
createElement(TerritoryFilters, {
state: { ...(currentQuery ? { q: currentQuery } : {}) },
regionOptions: [],
}),
elementWithSort: () =>
createElement(TerritoryFilters, {
state: { sort: 'region', dir: 'asc' },
regionOptions: [],
}),
},
{
name: 'municípios',
searchLabel: 'Buscar município',
hrefWithQuery: '/campanha/municipios?q=silva',
bareHref: '/campanha/municipios',
sortValue: 'name|asc',
element: (currentQuery) =>
createElement(MunicipalityFilters, {
state: { page: 1, ...(currentQuery ? { q: currentQuery } : {}) },
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
}),
elementWithSort: () =>
createElement(MunicipalityFilters, {
state: { page: 1, sort: 'name', dir: 'asc' },
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
}),
},
]
/**
* Always inside the boundary: in production all three shells render under one,
* so the local `useTransition` fallback is the branch that never runs.
*/
const mountCase = ({ element }: FilterCase, currentQuery?: string) => {
const { rerender } = render(
createElement(CampaignListPendingBoundary, null, element(currentQuery)),
)
return {
/** Re-renders with new committed state, as a navigation from elsewhere would. */
rerenderWith: (next: ReactElement) =>
rerender(createElement(CampaignListPendingBoundary, null, next)),
}
}
const typeSearch = (label: string, value: string) => {
fireEvent.change(screen.getByLabelText(label), { target: { value } })
}
const searchInput = (label: string) => screen.getByLabelText(label) as HTMLInputElement
/**
* `dir` never appears in these: every `sortValue` here is an `asc` key, which is
* each list's default direction, so the canonical serializer drops it.
*/
const sortKeyOf = ({ sortValue }: FilterCase) => sortValue.split('|')[0]
const hrefWithSort = (filterCase: FilterCase) =>
`${filterCase.bareHref}?sort=${sortKeyOf(filterCase)}`
const hrefWithQueryAndSort = (filterCase: FilterCase) =>
`${filterCase.bareHref}?q=silva&sort=${sortKeyOf(filterCase)}`
const advance = (ms: number) => {
act(() => {
vi.advanceTimersByTime(ms)
})
}
const reactActEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
describe('campaign list filter navigation', () => {
beforeAll(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
})
afterAll(() => {
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
})
beforeEach(() => {
// React's scheduler needs real microtasks; only the debounce clock is faked.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
})
afterEach(() => {
cleanup()
vi.useRealTimers()
routerState.replace.mockReset()
})
it.each(filterCases)(
'$name: debounces the search into a single canonical replace',
(filterCase) => {
const { searchLabel, hrefWithQuery } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'silva')
advance(SEARCH_DEBOUNCE_MS - 1)
expect(routerState.replace).not.toHaveBeenCalled()
advance(1)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQuery, { scroll: false })
},
)
it.each(filterCases)(
'$name: restarts the debounce window while the user keeps typing',
(filterCase) => {
const { searchLabel, hrefWithQuery } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'sil')
advance(SEARCH_DEBOUNCE_MS - 200)
typeSearch(searchLabel, 'silva')
advance(SEARCH_DEBOUNCE_MS - 200)
expect(routerState.replace).not.toHaveBeenCalled()
advance(200)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQuery, { scroll: false })
},
)
it.each(filterCases)(
'$name: skips navigation when the query would not change the URL',
(filterCase) => {
const { searchLabel } = filterCase
mountCase(filterCase, 'silva')
typeSearch(searchLabel, 'silva ')
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).not.toHaveBeenCalled()
fireEvent.submit(screen.getByRole('search'))
expect(routerState.replace).not.toHaveBeenCalled()
},
)
it.each(filterCases)('$name: cancels the pending search navigation on unmount', (filterCase) => {
const { searchLabel } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'silva')
cleanup()
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).not.toHaveBeenCalled()
})
it.each(filterCases)(
'$name: carries the uncommitted search when another control navigates',
(filterCase) => {
const { searchLabel, sortValue } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'silva')
fireEvent.change(screen.getByLabelText('Ordenar'), { target: { value: sortValue } })
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQueryAndSort(filterCase), {
scroll: false,
})
// The sort consumed the pending search; the timer must not fire a second one.
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).toHaveBeenCalledTimes(1)
},
)
it.each(filterCases)(
'$name: clears the box and the query in one deduped navigation',
(filterCase) => {
const { searchLabel, bareHref } = filterCase
mountCase(filterCase, 'silva')
fireEvent.click(screen.getByRole('button', { name: 'Limpar' }))
expect((screen.getByLabelText(searchLabel) as HTMLInputElement).value).toBe('')
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(bareHref, { scroll: false })
},
)
it.each(filterCases)(
'$name: a pending search does not revert a navigation made from outside the shell',
(filterCase) => {
const { searchLabel, elementWithSort } = filterCase
const { rerenderWith } = mountCase(filterCase)
typeSearch(searchLabel, 'silva')
// A sortable head / pagination link commits new state mid-debounce.
rerenderWith(elementWithSort())
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQueryAndSort(filterCase), {
scroll: false,
})
},
)
it.each(filterCases)(
'$name: follows the URL when the committed query is dropped from outside the shell',
(filterCase) => {
const { searchLabel, element } = filterCase
const { rerenderWith } = mountCase(filterCase, 'silva')
// The empty state's "Limpar busca e filtros" is an anchor: it drops `q`
// from the URL without this shell's controls being touched.
rerenderWith(element())
expect(searchInput(searchLabel).value).toBe('')
// The box must not put the dropped query back on the next filter touch.
fireEvent.change(screen.getByLabelText('Ordenar'), {
target: { value: filterCase.sortValue },
})
expect(routerState.replace).toHaveBeenCalledWith(hrefWithSort(filterCase), { scroll: false })
},
)
it.each(filterCases)(
'$name: the active-filters summary describes the typed query before it commits',
(filterCase) => {
const { searchLabel } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'silva')
// One copy per breakpoint: dobradinhas and territórios also render the
// summary in their mobile column, municípios only in the desktop row.
expect(screen.getAllByText(/Busca "silva"/).length).toBeGreaterThan(0)
expect(routerState.replace).not.toHaveBeenCalled()
},
)
it.each(filterCases)(
'$name: emptying a committed search navigates to the query-less URL',
(filterCase) => {
const { searchLabel, bareHref } = filterCase
mountCase(filterCase, 'silva')
typeSearch(searchLabel, '')
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(bareHref, { scroll: false })
},
)
})

View file

@ -23,7 +23,6 @@ import {
parseMunicipalitySortValue,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
} from '@/utilities/municipalityListUrl'
import {
formatMunicipalitySignalAgeLabel,
@ -33,19 +32,6 @@ import {
resolveMunicipalityLastSignalAt,
} from '@/utilities/municipalitySignal'
describe('shouldUpdateMunicipalitySearchUrl', () => {
it('returns false when canonical q matches the current URL q', () => {
expect(shouldUpdateMunicipalitySearchUrl('salvador', 'salvador')).toBe(false)
expect(shouldUpdateMunicipalitySearchUrl(' salvador ', 'salvador')).toBe(false)
})
it('returns true when q differs or is newly set', () => {
expect(shouldUpdateMunicipalitySearchUrl('salv', 'salvador')).toBe(true)
expect(shouldUpdateMunicipalitySearchUrl('salvador', undefined)).toBe(true)
expect(shouldUpdateMunicipalitySearchUrl('', 'salvador')).toBe(true)
})
})
describe('municipality list sort params (A11 + B15 + E9)', () => {
it('omits the default deficit/desc from the URL (E9 allocation queue order)', () => {
const state = parseMunicipalityListParams({ sort: 'deficit', dir: 'desc' })