Merge branch 'cursor/f95cf56d'
This commit is contained in:
commit
e712397241
20 changed files with 591 additions and 344 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
325
tests/unit/campaignListFilterNavigation.unit.spec.ts
Normal file
325
tests/unit/campaignListFilterNavigation.unit.spec.ts
Normal 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 })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
Loading…
Reference in a new issue