mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
B33+: shared list filter navigation hook
Extract the debounce/dedup/transition scaffold that StateDeputyFilters, TerritoryFilters and MunicipalityFilters each reimplemented into useCampaignListFilterNavigation, and unify the two behaviors that had silently diverged: touching a filter mid-typing used to discard the pending search on dobradinhas and territorios, and the debounce timer captured a stale state, so a sort clicked within its window was reverted a second later. Also folds the six copies of the pending-transition fallback into useCampaignListTransition and drops a redundant aria-live that made the municipality summary the third live region on its page.
This commit is contained in:
parent
862b717250
commit
abe37cbc5c
15 changed files with 483 additions and 300 deletions
|
|
@ -40,6 +40,7 @@ alwaysApply: false
|
|||
**UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`.
|
||||
|
||||
Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba".
|
||||
- **B33+ entregue em código (2026-07-27):** `useCampaignListFilterNavigation` (`src/components/campaign/shared/`) absorve o scaffold de navegação/debounce que `StateDeputyFilters`, `TerritoryFilters` e `MunicipalityFilters` reimplementavam — 216 linhas removidas contra 35 adicionadas, sem uma única linha de markup (`className`/`id`/`aria-*`/`htmlFor`) alterada nos três. **A auditoria pré-implementação derrubou a premissa central do plano:** ele rejeitava um callback único porque "os três domínios têm assinaturas de canonicalização diferentes", mas `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente** (`parseXListParams(xListStateToRawParams(...))` dentro do próprio builder) e `buildMunicipalityFiltersKey` é só `buildMunicipalityListSearchParams(state).toString()` — a mesma query string canônica. Logo o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante, não diferença de domínio, e um só `toHref` cobre os três; o guardrail de fundo (o hook recebe callback e nunca importa os módulos de URL por domínio) ficou de pé. **Achado de comportamento, não de estrutura:** tocar um filtro/sort no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (`navigateTo` chamava `clearDebounce()` primeiro) enquanto o input seguia exibindo o texto — só municípios carregava o rascunho. Unificado na semântica de municípios por `navigateWithSearch`, que é a **terceira** função exposta (a "questão em aberto" do plano fecha com três nomes, não dois); `clearDebounce` não é exposto porque `navigateTo` limpa antes de qualquer guarda, o que também matou o `router.replace` solto do "Limpar" de municípios. `shouldUpdateMunicipalitySearchUrl` orfanou (era `normalizedText(input) !== currentQ`, com o componente e o próprio spec como únicos consumidores) e foi deletado com seu `describe`. **Rede de segurança onde não havia nenhuma:** `tests/unit/campaignListFilterNavigation.unit.spec.ts` renderiza os três filtros em jsdom (`next/navigation` mockado, `vi.useFakeTimers({ toFake: ['setTimeout','clearTimeout'] })` — o scheduler do React precisa de microtasks reais), 5 casos × 3 listas; escrito antes da migração, o caso do rascunho nasceu **vermelho exatamente em dobradinhas e territórios**. `CampaignSearchForm` (lideranças/assessores/organizações) ficou fora por decisão explícita: 4º irmão, mas sem debounce, com submit explícito, e B29 provavelmente o substitui. Gate verde e bare (tsc, lint, format, knip, check:cycles, 555 unit + 413 int, build local); Aikido 0 achados. **e2e não serviu de veredito nesta sessão:** máquina com load average ~58 (dois outros worktrees com dev server), `campaignMunicipalities` falhando de forma instável na árvore nova **e** na árvore limpa em stash (3 → 6 → 4 falhas em rodadas idênticas, e verde isolado) — o flake pré-existente já ledgerizado, amplificado por timeout; `campaignTerritories` passou em todas as rodadas, e nenhum teste de municípios exercita o formulário de filtros (navegam direto por `?q=`), então o e2e nunca cobriu esta superfície. **Rodada `/simplify` (mesmo dia) mudou números e achou um bug:** o fallback `useCampaignListPending() ?? useTransition()` estava copiado em 5 lugares e o hook novo virou o 6º — virou `useCampaignListTransition()` em `CampaignListPending.tsx` (e `useCampaignListPending` deixou de ser exportado); `draftQ` saiu para o hook; `MunicipalityFilters` perdeu um `aria-live` na **terceira** região viva da mesma página (anunciava o resumo de filtros a cada tecla) e um `div` de um filho só. O bug: o `setTimeout` do debounce capturava o `state` do render que o agendou, então em territórios/dobradinhas digitar + clicar num sort dentro de 1s **revertia o sort** um segundo depois (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. Números finais: 281 linhas removidas contra 75 em todo o `src`, 8 casos × 3 listas, 562 unit + 413 int. Fica **parado com gatilho** (os dois revisores propuseram direções opostas): ressincronizar o input com `state.q` — remontar atropela quem digita rápido, não remontar deixa o input mentindo depois do Back. Plano: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md).
|
||||
- **B33 entregue em código (2026-07-26):** ordenação e filtro no header de `/campanha/dobradinhas` — `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` (contrato de URL/filtro próprio, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido") espelhando `municipalityListUrl.ts` (paginação/`resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) — não `leadershipListUrl.ts`, que o plano mandava espelhar mas **não existe** (B29 não entregou). `StateDeputySortableHead`/`StateDeputyHeaderFilter` consomem o chrome já compartilhado pelo **B21 ✓** sem extrair de novo (a corrida de ordem de chegada com B29 está encerrada); `loadStateDeputyPartyFacet` segue o contrato de faceta de `loadMunicipalityListFilterFacets` (respeita a busca, ignora o próprio filtro do popover). Ordem dos nulos de `party` **pinada por int test** em vez de assumida — Postgres/Payload ordena nulls por último em `asc` e primeiro em `desc`; "Sem partido" continua sendo o caminho direto via `party: { exists: false }`. Paridade mobile (`StateDeputyFilters`, espelho de `TerritoryFilters`) entrou por decisão desta sessão, fora do appetite original. Redirect canônico em duas fases (`resolveStateDeputyListUrl` → `totalPages` → resolve de novo) no molde de `municipios/page.tsx`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. **Correção pós-entrega no mesmo dia (design review independente):** mobile não tinha a opção "Sem partido" que o popover desktop já injetava (`hasNoParty` não chegava a `StateDeputyFilters`) e a tabela não passava `caption` ao `CampaignTable`, ao contrário dos dois precedentes citados no plano — ambos corrigidos no mesmo molde; copy do empty state também virou plural. **`/simplify` no mesmo dia:** a correção acima tinha copiado a montagem da linha "Sem partido" em vez de compartilhá-la — extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido por `StateDeputyFilters` e `StateDeputyHeaderFilter`; `StateDeputySortableHead` perdeu o discriminante `filterParam: 'party'` (só há uma coluna filtrável, `filterOptions` já basta); `parsePartiesParam` parou de reimplementar o dedupe de `allParamValues`; um `if` sempre-verdadeiro saiu de `buildStateDeputyListWhere`; `loadStateDeputyListPageData` ganhou `select` na query principal. Gate reverificado verde. **`/simplify` pós-`/rebase-on-main` (2ª rodada, mesmo dia):** rodou de novo sobre o commit squashado — removeu tipo+cast morto `StateDeputyPartyRow` (`stateDeputyData.ts`, `select` só afeta runtime, não a checagem estática), `stateDeputyColumns` (`page.tsx`) passou a importar `StateDeputyFilterOption` em vez de redeclarar a forma, `parsePartiesParam` foi inlineado (helper de uma linha usado uma vez). Achado maior: `firstPartyNamesLabel` era a 3ª cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites"; extraído `truncatedNamesLabel` em `campaignListUrl.ts`, os três domínios migrados. Recomendação de hook compartilhado para o scaffold de busca debounced (`StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo ~50 linhas) registrada mas não perseguida — tocaria os três domínios, fora do escopo deste item; capturada no fechamento como **B33+** (fill-in de engenharia, `useCampaignListFilterNavigation`, ~0,5d). Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. Agregado SQL para o facet de partido também adiado com gatilho (só compensa em escala). Plano: [`ordenacao-filtros-lista-dobradinhas.md`](../../../docs/plans/ordenacao-filtros-lista-dobradinhas.md) · fill-in: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md).
|
||||
- **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover <nome>"`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
|
||||
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,32 @@
|
|||
# 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. `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.
|
||||
|
||||
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.
|
||||
|
||||
**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 +91,11 @@ Componentes:
|
|||
|
||||
## Adiado com gatilho
|
||||
|
||||
Nenhum neste item.
|
||||
Três achados do `/simplify` (reuse review, 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.
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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,15 +1,11 @@
|
|||
'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,
|
||||
clearMunicipalityListFilters,
|
||||
|
|
@ -22,18 +18,14 @@ 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,18 @@ 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, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } =
|
||||
useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => buildMunicipalityListHref(next, 1),
|
||||
})
|
||||
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,7 +67,7 @@ export const MunicipalityFilters = ({
|
|||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
commitNavigation({ q: search })
|
||||
navigateWithSearch(state)
|
||||
}}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
|
|
@ -144,37 +83,26 @@ export const MunicipalityFilters = ({
|
|||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setSearch(value)
|
||||
scheduleSearchNavigation(value)
|
||||
scheduleSearch(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={() => {
|
||||
setSearch('')
|
||||
navigateTo(clearMunicipalityListFilters(state))
|
||||
}}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -186,10 +114,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 +134,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 +153,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 +185,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 +197,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 +209,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 +220,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, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } =
|
||||
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,7 +42,7 @@ export const TerritoryFilters = ({
|
|||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigateTo({ ...state, q: search })
|
||||
navigateWithSearch(state)
|
||||
}}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
|
|
@ -120,7 +71,6 @@ export const TerritoryFilters = ({
|
|||
variant="ghost"
|
||||
className="min-h-11 md:self-end"
|
||||
onClick={() => {
|
||||
clearDebounce()
|
||||
setSearch('')
|
||||
navigateTo(clearTerritoryListFilters(state))
|
||||
}}
|
||||
|
|
@ -138,7 +88,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 +97,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 +123,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,111 @@
|
|||
'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.
|
||||
*/
|
||||
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 [search, setSearch] = useState(state.q ?? '')
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const stateRef = useRef(state)
|
||||
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.
|
||||
*
|
||||
* Cancelling before the guard is why `clearDebounce` is not exposed: the
|
||||
* three "Limpar" buttons need nothing beyond `setSearch('') + navigateTo`.
|
||||
*/
|
||||
const navigateTo = (next: State) => {
|
||||
clearDebounce()
|
||||
const nextHref = toHref(next)
|
||||
if (nextHref === toHref(stateRef.current)) return
|
||||
startTransition(() => {
|
||||
router.replace(nextHref, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const navigateWithSearch = (next: State) => {
|
||||
navigateTo({ ...next, q: normalizedText(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.
|
||||
*/
|
||||
const scheduleSearch = (value: string) => {
|
||||
clearDebounce()
|
||||
debounceRef.current = setTimeout(() => {
|
||||
navigateTo({ ...stateRef.current, q: value })
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
draftQ: normalizedText(search) || state.q,
|
||||
isPending,
|
||||
navigateTo,
|
||||
navigateWithSearch,
|
||||
scheduleSearch,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
buildStateDeputyPartyOptions,
|
||||
clearStateDeputyListFilters,
|
||||
|
|
@ -19,17 +15,13 @@ import {
|
|||
} 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,14 @@ 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, setSearch, draftQ, isPending, navigateTo, navigateWithSearch, scheduleSearch } =
|
||||
useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => buildStateDeputyListHref(next, 1),
|
||||
})
|
||||
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,7 +50,7 @@ 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">
|
||||
|
|
@ -126,7 +79,6 @@ export const StateDeputyFilters = ({
|
|||
variant="ghost"
|
||||
className="min-h-11 md:self-end"
|
||||
onClick={() => {
|
||||
clearDebounce()
|
||||
setSearch('')
|
||||
navigateTo(clearStateDeputyListFilters(state))
|
||||
}}
|
||||
|
|
@ -144,7 +96,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 +106,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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -435,11 +435,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,
|
||||
|
|
|
|||
267
tests/unit/campaignListFilterNavigation.unit.spec.ts
Normal file
267
tests/unit/campaignListFilterNavigation.unit.spec.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
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'
|
||||
|
||||
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 advance = (ms: number) => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms)
|
||||
})
|
||||
}
|
||||
|
||||
describe('campaign list filter navigation', () => {
|
||||
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)
|
||||
const [href] = routerState.replace.mock.calls[0] as [string, unknown]
|
||||
expect(href).toContain('q=silva')
|
||||
expect(href).toContain(`sort=${sortValue.split('|')[0]}`)
|
||||
|
||||
// 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, sortValue, 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)
|
||||
const [href] = routerState.replace.mock.calls[0] as [string, unknown]
|
||||
expect(href).toContain('q=silva')
|
||||
expect(href).toContain(`sort=${sortValue.split('|')[0]}`)
|
||||
},
|
||||
)
|
||||
|
||||
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