B33+: close the /simplify rounds on the shared list filter hook
Follows the URL when the committed query changes with no debounce pending, so Back/Forward and the empty state's clear anchor update the search box without overwriting text the user is still typing. That removed the last reason for /campanha/municipios to remount its filters, so the key= (and its now orphaned buildMunicipalityFiltersKey) is gone and the three lists share one mounting policy — the dedup guard was dead code on municípios while it remounted. Reuse: setSearch + scheduleSearch were always called as a pair, so they collapse into onSearchChange (hook surface 8 -> 6 names); MunicipalityFilters reuses the buildMunicipalityFilterHref it was reimplementing inline; dobradinhas gains the sibling buildStateDeputyFilterHref for the four sites pinning page 1 by hand; municípios gains the active-filter summary its mobile block was missing. Also drops an export with no consumer that was failing knip in CI (seo.ts).
This commit is contained in:
parent
abe37cbc5c
commit
bcb58c2162
14 changed files with 166 additions and 102 deletions
File diff suppressed because one or more lines are too long
|
|
@ -12,7 +12,7 @@ Responsável: —
|
|||
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. `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.
|
||||
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.
|
||||
|
||||
|
|
@ -23,7 +23,11 @@ Entregue como planejado no essencial — um hook em `shared/`, três call sites,
|
|||
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.
|
||||
|
||||
Saldo medido no fim (incluindo a rodada de `/simplify`): **244 linhas removidas contra 48 adicionadas nos três filtros**, 281 contra 75 em todo o `src`, contra as 111 linhas do hook novo (metade comentário) — ~95 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.
|
||||
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.
|
||||
|
||||
|
|
@ -91,11 +95,15 @@ Componentes:
|
|||
|
||||
## Adiado com gatilho
|
||||
|
||||
Três achados do `/simplify` (reuse review, 2026-07-27) que são maiores que limpeza:
|
||||
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` — e os dois revisores discordaram sobre a direção, o que é o próprio sinal de que não é limpeza.** Só `/campanha/municipios` remonta seus filtros (`key={buildMunicipalityFiltersKey(state)}`); nos outros dois, Back/Forward ou a âncora "Limpar busca e filtros" do estado vazio deixam o input com o rascunho antigo enquanto os resultados já mudaram. O reuse review propôs ressincronizar no hook (e então **remover** o `key` de municípios); o code-quality review propôs **adicionar** o `key` às outras duas páginas. São opostos, e cada um paga com o que o outro protege: ressincronizar/remontar atropela quem digita rápido (a navegação de "sal" aterrissa e sobrescreve o "sal x" já digitado — o que municípios faz hoje), e não fazer nada mantém o input mentindo depois do Back. O ponto 8 tirou daqui a metade que **era** bug puro (a reversão silenciosa do sort), sem escolher lado nessa; o que resta é decisão de produto, com um terceiro caminho (ressincronizar só quando a navegação não foi nossa) que custa máquina. **Gatilho:** relato de campo de input dessincronizado, ou a adoção do shell acima, que já toca os três.
|
||||
- **`SupporterFilters` / `ActivityFilters` adotarem o hook.** Ganharam `useCampaignListTransition`, mas não a navegação: os dois montam o href de `usePathname()` + `buildXListSearchParams` sem passo de parse, e mantêm um `valuesRef` espelhando todos os filtros, que o hook não tem equivalente. Migrar lhes **daria** dedup de no-op — mudança de comportamento em duas superfícies vivas. **Gatilho:** quando um deles ganhar módulo de URL por domínio.
|
||||
- ~~**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
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md))
|
||||
- **C8 F1–F2 restantes** perf de import/lista (F3 parsers fechou no Pass 2 W1-D2) · gatilho: import em volume real pós-Onda 0 · [plano](plans/escala-dry-pos-c6.md)
|
||||
- ~~**C8 F4b**~~ ✓ (entregue 2026-07-27) o guard de `formActions` só inspecionava arquivos chamados exatamente `formActions.ts` — ampliado para `*FormActions.ts` + `taskActions.ts`; 6 irmãos migrados para `runCampaignFormAction`/`runCampaignRedirectFormAction`; `taskActions.ts` allowlisted (contrato posicional) · achado do `/simplify` do C13 · [plano](plans/escala-dry-pos-c6.md)
|
||||
- ~~**B33+**~~ ✓ escala/DRY pós-B33 (entregue 2026-07-27) — `useCampaignListFilterNavigation` em `shared/` absorve o scaffold de navegação/debounce dos três filtros (281 linhas removidas contra 75 em `src`, JSX intocado fora de um `aria-live` que era bug); a auditoria derrubou o racional das "assinaturas diferentes" (os três `buildXListHref` já canonicalizam, então um só callback `toHref` basta) e achou **dois** bugs de comportamento: dobradinhas e territórios descartavam a busca digitada ao tocar um filtro (unificado na semântica de municípios via `navigateWithSearch`) e o timer do debounce capturava o `state` do render que o agendou, revertendo um sort clicado dentro da janela de 1s — municípios escapava pelo `key=` da página, que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` somou o fallback de transition copiado em 5 lugares (o hook era o 6º) virando `useCampaignListTransition`, e um `aria-live` a menos; `shouldUpdateMunicipalitySearchUrl` orfanou e morreu; `campaignListFilterNavigation.unit.spec.ts` (8 casos × 3 listas) é a primeira cobertura desses três componentes · [plano](plans/escala-dry-pos-b33.md)
|
||||
- ~~**B33+**~~ ✓ escala/DRY pós-B33 (entregue 2026-07-27) — `useCampaignListFilterNavigation` em `shared/` absorve o scaffold de navegação/debounce dos três filtros (**325 linhas removidas contra 228 em `src`**, das quais 138 são o hook novo; JSX só se moveu onde era bug); a auditoria derrubou o racional das "assinaturas diferentes" (os três `buildXListHref` já canonicalizam, então um só callback `toHref` basta) e achou **dois** bugs de comportamento: dobradinhas e territórios descartavam a busca digitada ao tocar um filtro (unificado na semântica de municípios via `navigateWithSearch`) e o timer do debounce capturava o `state` do render que o agendou, revertendo um sort clicado dentro da janela de 1s — municípios escapava pelo `key=` da página, que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix). O `/simplify` somou o fallback de transition copiado em 5 lugares (o hook era o 6º) virando `useCampaignListTransition`, e um `aria-live` a menos; a 2ª rodada fechou a ressincronização da caixa de busca com o `q` commitado (o que permitiu **remover** o `key=` de municípios e unificar a política de montagem das três listas — `buildMunicipalityFiltersKey` orfanou e morreu, junto com `shouldUpdateMunicipalitySearchUrl`), colapsou `setSearch`+`scheduleSearch` num `onSearchChange` (superfície de 8 → 6 nomes), passou a reusar o `buildMunicipalityFilterHref` que já existia + criou o irmão `buildStateDeputyFilterHref` (4 sítios pinavam página 1 à mão), e deu a municípios o resumo de filtros ativos que faltava no bloco mobile; `campaignListFilterNavigation.unit.spec.ts` (10 casos × 3 listas) é a primeira cobertura desses três componentes. **Dívidas deferidas com gatilho** (no plano, § "Adiado com gatilho"): shell de `<form>` compartilhado (gatilho B29), `CampaignListSortSelect` (bloqueado numa decisão de altura de select, já divergida entre as três), `serialize`/`parseXSortValue` triplicado → `campaignListUrl`, `SupporterFilters`/`ActivityFilters` reconstruírem href à mão tendo builders pinados, ~10 kB gzip do catálogo no bundle cliente de `/campanha/municipios` (pré-existente, achado da review de performance) e o 3º idioma de mock de router nos specs · [plano](plans/escala-dry-pos-b33.md)
|
||||
- Higiene PascalCase de componentes legados
|
||||
|
||||
### A validar (assumptions)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Field, FieldLabel } from '@/components/ui/field'
|
|||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import {
|
||||
applyMunicipalityKindFilter,
|
||||
buildMunicipalityFilterHref,
|
||||
clearMunicipalityListFilters,
|
||||
formatMunicipalityActiveFiltersSummary,
|
||||
getMunicipalityFilterDefinition,
|
||||
|
|
@ -18,7 +19,6 @@ import {
|
|||
type MunicipalityFilterOption,
|
||||
} from '@/utilities/municipalityListFilters'
|
||||
import {
|
||||
buildMunicipalityListHref,
|
||||
municipalityListSortOptions,
|
||||
parseMunicipalitySortValue,
|
||||
resolveMunicipalityListSort,
|
||||
|
|
@ -41,11 +41,8 @@ export const MunicipalityFilters = ({
|
|||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
}: MunicipalityFiltersProps) => {
|
||||
const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } =
|
||||
useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => buildMunicipalityListHref(next, 1),
|
||||
})
|
||||
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
|
||||
useCampaignListFilterNavigation({ state, toHref: buildMunicipalityFilterHref })
|
||||
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
|
||||
const activeFiltersSummary = formatMunicipalityActiveFiltersSummary({
|
||||
...state,
|
||||
|
|
@ -70,21 +67,13 @@ export const MunicipalityFilters = ({
|
|||
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)
|
||||
scheduleSearch(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">
|
||||
|
|
@ -96,10 +85,7 @@ export const MunicipalityFilters = ({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 shrink-0 md:self-end"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
navigateTo(clearMunicipalityListFilters(state))
|
||||
}}
|
||||
onClick={() => clearSearchAndNavigate(clearMunicipalityListFilters(state))}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
|
|
@ -107,6 +93,9 @@ export const MunicipalityFilters = ({
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const TerritoryFilters = ({
|
|||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
}) => {
|
||||
const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } =
|
||||
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
|
||||
useCampaignListFilterNavigation({ state, toHref: buildTerritoryListHref })
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
const activeSummary = formatTerritoryActiveFiltersSummary({ ...state, q: draftQ })
|
||||
|
|
@ -45,20 +45,13 @@ export const TerritoryFilters = ({
|
|||
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">
|
||||
|
|
@ -70,10 +63,7 @@ export const TerritoryFilters = ({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 md:self-end"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
navigateTo(clearTerritoryListFilters(state))
|
||||
}}
|
||||
onClick={() => clearSearchAndNavigate(clearTerritoryListFilters(state))}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ export const SEARCH_DEBOUNCE_MS = 1000
|
|||
*
|
||||
* 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.
|
||||
* 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,
|
||||
|
|
@ -32,16 +34,34 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
|
|||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const [search, setSearch] = useState(state.q ?? '')
|
||||
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, the empty state's "Limpar" — 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.
|
||||
* 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)
|
||||
stateRef.current = state
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
})
|
||||
|
||||
const clearDebounce = () => {
|
||||
if (!debounceRef.current) return
|
||||
|
|
@ -56,9 +76,6 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
|
|||
* 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.
|
||||
*
|
||||
* Cancelling before the guard is why `clearDebounce` is not exposed: the
|
||||
* three "Limpar" buttons need nothing beyond `setSearch('') + navigateTo`.
|
||||
*/
|
||||
const navigateTo = (next: State) => {
|
||||
clearDebounce()
|
||||
|
|
@ -70,24 +87,34 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
|
|||
}
|
||||
|
||||
/**
|
||||
* Same as `navigateTo`, 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.
|
||||
*
|
||||
* Not interchangeable with `navigateTo`: "Limpar" calls `setSearch('')` and
|
||||
* that setter has not flushed yet, so going through here would re-commit the
|
||||
* search the user just cleared.
|
||||
* 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: normalizedText(search) })
|
||||
navigateTo({ ...next, q: search })
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing back to the committed value cancels a pending navigation rather
|
||||
* than merely skipping a new one — the guard lives in `navigateTo`, so the
|
||||
* timer is free to resolve to a no-op.
|
||||
* 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 scheduleSearch = (value: string) => {
|
||||
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 })
|
||||
|
|
@ -96,16 +123,16 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
|
|||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
onSearchChange,
|
||||
/**
|
||||
* What the active-filters summary should describe: the text being typed,
|
||||
* falling back to the committed `q` while the box is empty (the results on
|
||||
* screen are still the committed query's).
|
||||
* 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,
|
||||
navigateTo,
|
||||
navigateWithSearch,
|
||||
scheduleSearch,
|
||||
clearSearchAndNavigate,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import {
|
||||
buildStateDeputyFilterHref,
|
||||
buildStateDeputyPartyOptions,
|
||||
clearStateDeputyListFilters,
|
||||
formatStateDeputyActiveFiltersSummary,
|
||||
|
|
@ -14,7 +15,6 @@ import {
|
|||
type StateDeputyFilterOption,
|
||||
} from '@/utilities/stateDeputyListFilters'
|
||||
import {
|
||||
buildStateDeputyListHref,
|
||||
parseStateDeputySortValue,
|
||||
resolveStateDeputyListSort,
|
||||
serializeStateDeputySortValue,
|
||||
|
|
@ -33,11 +33,8 @@ export const StateDeputyFilters = ({
|
|||
/** Whether at least one facet-matching row has no party — gates the "Sem partido" option. */
|
||||
hasNoParty: boolean
|
||||
}) => {
|
||||
const { search, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } =
|
||||
useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => buildStateDeputyListHref(next, 1),
|
||||
})
|
||||
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: draftQ })
|
||||
|
|
@ -53,20 +50,13 @@ export const StateDeputyFilters = ({
|
|||
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">
|
||||
|
|
@ -78,10 +68,7 @@ export const StateDeputyFilters = ({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 md:self-end"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
navigateTo(clearStateDeputyListFilters(state))
|
||||
}}
|
||||
onClick={() => clearSearchAndNavigate(clearStateDeputyListFilters(state))}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, createElement, type ReactElement } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const routerState = vi.hoisted(() => ({
|
||||
replace: vi.fn(),
|
||||
|
|
@ -121,13 +121,37 @@ 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'] })
|
||||
|
|
@ -209,9 +233,9 @@ describe('campaign list filter navigation', () => {
|
|||
fireEvent.change(screen.getByLabelText('Ordenar'), { target: { value: sortValue } })
|
||||
|
||||
expect(routerState.replace).toHaveBeenCalledTimes(1)
|
||||
const [href] = routerState.replace.mock.calls[0] as [string, unknown]
|
||||
expect(href).toContain('q=silva')
|
||||
expect(href).toContain(`sort=${sortValue.split('|')[0]}`)
|
||||
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)
|
||||
|
|
@ -236,7 +260,7 @@ describe('campaign list filter navigation', () => {
|
|||
it.each(filterCases)(
|
||||
'$name: a pending search does not revert a navigation made from outside the shell',
|
||||
(filterCase) => {
|
||||
const { searchLabel, sortValue, elementWithSort } = filterCase
|
||||
const { searchLabel, elementWithSort } = filterCase
|
||||
const { rerenderWith } = mountCase(filterCase)
|
||||
|
||||
typeSearch(searchLabel, 'silva')
|
||||
|
|
@ -245,9 +269,43 @@ describe('campaign list filter navigation', () => {
|
|||
advance(SEARCH_DEBOUNCE_MS)
|
||||
|
||||
expect(routerState.replace).toHaveBeenCalledTimes(1)
|
||||
const [href] = routerState.replace.mock.calls[0] as [string, unknown]
|
||||
expect(href).toContain('q=silva')
|
||||
expect(href).toContain(`sort=${sortValue.split('|')[0]}`)
|
||||
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()
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue