Merge branch 'cursor/65f0719c'
Land B18 saved municipality list filters (localStorage + sidebar submenu).
This commit is contained in:
commit
1badfc85dc
18 changed files with 1066 additions and 129 deletions
|
|
@ -29,6 +29,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
|
|||
- URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18).
|
||||
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `<CampaignTableHead>{label}</CampaignTableHead>`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas de domínio sobre `shared/CampaignSortableHead` + `shared/CampaignHeaderFilterPopover` (multi-seleção: opções marcadas sobem ao topo via `lib/listFilterOptions.ts` — snapshot na abertura; gate = `row.checkbox`; single-select intocado). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível.
|
||||
- Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle).
|
||||
- Filtros salvos (B18) — só municípios: `utilities/municipalitySavedFilters.ts` (`localStorage`, identidade = href canônico, ordem alfabética) + hook `shared/useMunicipalitySavedFilters` + `municipality/SaveMunicipalityFilterControl` (barra) + `shell/MunicipalityNavSavedFilters` (submenu). O casamento URL↔salvo é `lib/listQueryMatch.ts` e **tem de continuar puro**: a sidebar está no layout de `(app)`, então importar o serializador de URL do domínio ali custa ~21 kB em toda rota `/campanha`.
|
||||
- Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`.
|
||||
- Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora.
|
||||
- Detalhe por tabs: `utilities/detailTabUi.ts` (factory) + sections RSC colocadas na rota (`municipios/[slug]/MunicipalityDetailTabs.tsx`, `atividades/[slug]/ActivityOverviewTab.tsx`).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,142 +1,76 @@
|
|||
# B18 — Filtros salvos na lista de Municípios (+ acesso rápido no sidebar)
|
||||
|
||||
Status: rascunho — **costuras prontas desde o Pass 2 W1 (2026-07-25):** o estado de URL é canônico e serializável (`municipalityListUrl.ts`, contrato congelado); salvar filtro = nomear a serialização atual
|
||||
Atualizado em: 2026-07-24
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B18; superfície de coordenação)
|
||||
Impeccable: B — encaixe em `MunicipalityFilters` / barra slim + `CampaignSidebar` (2º nível sob Municípios); sem rota nova
|
||||
Appetite: ~1–1,5 dia eng; persistência local + Salvar com nome + submenu hover/sticky no sidebar; sem migration, sem collection, sem Consent
|
||||
Status: ✓ entregue (2026-07-28) — sem migration, sem collection, sem server action, sem `Consent`
|
||||
Atualizado em: 2026-07-28
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (B18; superfície de coordenação)
|
||||
Impeccable: B — craft + critique + polish em `MunicipalityFilters` e `CampaignSidebar`; sem rota nova
|
||||
Appetite: ~1–1,5 dia eng (gasto: dentro)
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
## O que ficou de pé
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2 — clareza sob pressão — e 8 — Feel the action; exceção justificada ao “Auto-save, no Save button” porque nomear é confirmatório) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · shells `CampaignSidebar`, `MunicipalityFilters`, primitivos `SidebarMenuSub*` já em `src/components/ui/Sidebar.tsx`.
|
||||
Em `/campanha/municipios`, um botão **Salvar filtro** ao lado de **Limpar** nomeia o recorte atual da URL, e o atalho passa a viver como **submenu de 2º nível sob Municípios** no sidebar — com chevron, estado persistido, item ativo, apagar com desfazer.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só salvar + submenu; sem redesign da nav completa nem sync multi-device).
|
||||
Três camadas:
|
||||
|
||||
Brief compacto:
|
||||
- **`src/utilities/municipalitySavedFilters.ts`** — `localStorage` com cache de snapshot para `useSyncExternalStore`, `MAX_ENTRIES = 12`, `MAX_NAME_LENGTH = 60`, ordem alfabética, limpeza no logout.
|
||||
- **`src/lib/listQueryMatch.ts`** — `isSameListHref(a, b, ignoredParams)`, puro e sem imports pesados.
|
||||
- **`MunicipalityNavSavedFilters`** + **`SaveMunicipalityFilterControl`**, sincronizados pelo hook compartilhado `useMunicipalitySavedFilters`.
|
||||
|
||||
- **Persona / contexto:** Alex (CG / Assessor / Candidato) monta o mesmo recorte várias vezes na semana (ex. “Prioritárias sem assessor · TI X · sort votos”) e precisa voltar a ele em um toque a partir da nav — sem refazer selects.
|
||||
- **Job principal:** nomear o estado atual da URL da lista e reabri-lo como atalho de 2º nível sob **Municípios** no sidebar.
|
||||
- **Estratégia de cor:** Restrained — submenu indentado com `border-l` do `SidebarMenuSub`; active sóbrio (`data-active`); sem chips coloridos de “views”.
|
||||
- **Edit where you see:** não — bookmark de navegação URL; mutações B9 nas células seguem iguais.
|
||||
- **Anti-goals:** collection/`campaignUser` prefs; saved views genéricas em todas as listas; segundo nível sempre visível (polui nav); sticky em qualquer `/campanha/municipios` (só no filtro salvo ativo); auto-gravar sem nome (vira “Visitados”).
|
||||
## Premissas do plano de 2026-07-24 que morreram na auditoria
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
Registradas porque cada uma custou trabalho que o appetite original não previa, e porque voltar a acreditar nelas custaria de novo:
|
||||
|
||||
- **Vou apresentar dados?** Não — **Dados: N/A**. Este item não inventa métrica; só persiste e reabre o **mesmo** recorte URL que a lista já resolve.
|
||||
- **Decisões desbloqueadas:** Staff: “voltar ao recorte mental desta sessão (ex. prioritárias / TI / sem assessor) sem remontar filtros.”
|
||||
- **Forma escolhida:** **lista/tabela existente** + links nomeados no sidebar — **por quê:** o dado já está na lista; o problema é atrito de remontagem. **Rejeitado:** dashboard de “minhas views”; chart de uso de filtros; segunda página de gestão de views.
|
||||
- **Profile:** N/A.
|
||||
- **Anti-goals de dado:** sem KPI de “quantos filtros salvos”; sem % estadual.
|
||||
1. **`SidebarMenuSub` / `SidebarMenuSubItem` / `SidebarMenuSubButton` / `SidebarMenuAction` NÃO existiam.** O plano dizia "existem no shadcn local e não são usados" — o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado. Foi preciso **re-adicioná-los** do upstream. Ao fazê-lo apareceu um bug latente do kit: React renderiza `data-active={false}` como `data-active="false"`, e o seletor Tailwind `data-active:` casa com `[data-active]` — ou seja, **todo item não-ativo estava recebendo o estilo de ativo**. Agora o atributo só é emitido quando verdadeiro (`data-active={isActive || undefined}`), em `SidebarMenuButton` e `SidebarMenuSubButton`.
|
||||
2. **`src/utilities/municipalityUi.ts` não existe** — o Pass 2 W1 o dividiu em `municipalityListUrl.ts`, `municipalityListFilters.ts`, `municipalityLabels.ts` e `municipalitySignal.ts`. `buildMunicipalitySavedFilterHref` nasceu em `municipalityListFilters.ts`.
|
||||
3. **O estado da URL cresceu** muito além do que o plano listava (`q`, `regions[]`, `slugs[]`, `advisors[]`, `kind`, `coverage`, `priority`, `trends[]`, `classes[]`, `levels[]`, `compare`, `sort`, `dir`, `page`). Serializar o estado inteiro menos `page`/`compare` absorveu isso sozinho — nenhuma lista de params foi escrita à mão.
|
||||
4. **B17 persiste em cookie, não `localStorage`.** A linha de contexto do plano estava errada. Não muda a decisão (o bookmark não precisa chegar ao render do servidor).
|
||||
5. **"Ao lado de Limpar / Colunas" como uma fileira só é impossível:** `Limpar` está em `MunicipalityFilters` e `Colunas` está dentro de `CampaignTable`, numa fileira compartilhada pelas sete listas. Decidido na sessão: **barra de filtros, ao lado de Limpar**.
|
||||
6. **`municipalityListHasSavableFilters` não precisou existir.** `formatMunicipalityActiveFiltersSummary` já é o gate "tem filtro?" (é o que o `Limpar` usa) **e** já produz o rótulo legível de todos os filtros — então ele é ao mesmo tempo a condição de montagem do botão e o **nome padrão** sugerido no Popover.
|
||||
7. **Disclosure por hover morreu com B38 ✓.** Com `collapsible="offcanvas"`, recolhido no desktop a sidebar sai inteira da tela e não sobra rail para hover. Virou **collapsible com chevron e estado persistido**, decidido com o usuário.
|
||||
8. **Renomear entrou no v1**, ao contrário do "adiado" do plano: como a identidade é o href e re-salvar é upsert, o botão simplesmente lê **"Renomear"** quando o recorte atual já está salvo, e o Popover abre pré-preenchido. Custou uma string, não uma fase.
|
||||
|
||||
Self-check dados: N/A (sem superfície de métrica nova).
|
||||
## Decisões de engenharia que valem guardar
|
||||
|
||||
## Contexto
|
||||
- **O casamento "URL atual == filtro salvo" não pode usar o serializador de URL do domínio.** A sidebar está no layout de `(app)`, então tudo que ela importa entra no First Load JS de **toda** rota `/campanha`, e o AGENTS.md já registra que importar `buildMunicipalityListHref` num island custou **21 kB** (arrasta `bahiaTerritories` + `municipalityCatalog`). Daí `src/lib/listQueryMatch.ts`: comparação de `URLSearchParams` insensível à ordem, que de quebra resolve um caso que o plano não previu — **estar na página 3 de um filtro salvo ainda é estar nele** (`page` é posição dentro do recorte, não parte dele).
|
||||
- **Medido contra árvore limpa (`git stash`), que é a única forma de afirmar isso:** `/campanha` ficou **inalterado em 277 kB**; `/campanha/municipios` foi de **328 → 330 kB**. A fase `optimize` não disparou.
|
||||
- **A identidade é o href, não um UUID.** O plano previa `id` + `name` + `href` + `savedAt`; sobrou `{ href, name }`. Um UUID só existiria para permitir dois atalhos que navegam para o mesmo lugar, que é exatamente o que o upsert existe para impedir — e `savedAt` não é lido por nada, porque a ordem é **alfabética** de propósito: um atalho nomeado que se reordena sozinho sob o cursor cada vez que é re-salvo é um bug de navegação. Recência é o trabalho de Visitados.
|
||||
- **O teto é checado contra as OUTRAS entradas** (`others.length >= MAX_ENTRIES`), não contra o tamanho armazenado — senão renomear no limite passaria a ser recusado.
|
||||
- **O `useSyncExternalStore` tem o server snapshot como constante congelada.** Devolver `[]` fresco ali é o loop de render clássico; o snapshot do cliente é cacheado e invalidado por escrita e por `storage` de outra aba (o evento nativo não dispara na aba que escreveu — daí o evento custom que cobre a outra metade).
|
||||
|
||||
Em `/campanha/municipios`, o estado canônico de recorte/ordem vive na URL via `MunicipalityListState` (`src/utilities/municipalityUi.ts`: `q`, `region`, `kind`, `coverage`, `priority`, `trend`, `sort`/`dir`, `page`, `compare`). Helpers já canonicalizam href (`resolveMunicipalityListUrl` / `buildMunicipalityListHref` / `buildMunicipalityListVisitHref`). **B15** ✓ sort; **B16** relocaciona filtros ao header e deixa barra slim; **B17** preferência de colunas em `localStorage`.
|
||||
## Dois bugs reais que só o e2e pegou
|
||||
|
||||
O sidebar (`CampaignSidebar.tsx` + `nav.ts`) é **flat** — um `SidebarMenuButton` por item; primitivos `SidebarMenuSub` / `SidebarMenuSubButton` existem no shadcn local e **não são usados**. “Visitados recentemente” (`recentVisits.ts`) já prova o padrão client-only + limpeza no logout, mas é histórico automático — não nomeado.
|
||||
1. **O auto-open reabria o grupo em toda montagem, tornando a preferência persistida inobservável exatamente para quem mais usa filtros salvos:** o usuário recolhe o grupo, recarrega, e ele volta. A causa não é óbvia — o gatilho era `activeHref` aparecer, mas **o store responde vazio até o cliente ler o `localStorage`**, então um match surgindo da hidratação se parecia com uma navegação. O gatilho passou a ser a **URL mudar**; a preferência guardada vence na montagem e o "revelar onde você está" fica reservado à navegação que de fato muda o atalho corrente.
|
||||
2. **Apagar uma linha derrubava o foco para `<body>`.** O sucessor é resolvido **antes** da remoção, porque esvaziar o grupo desmonta o componente inteiro — chevron incluído — e nesse caso o alvo tem de ser o link de nav.
|
||||
|
||||
Pedido de produto (2026-07-24): **salvar** o filtro atual com um **nome**, e expor os salvos como **acesso rápido de 2º nível** sob Municípios: aparecem no **hover** (desktop) ou **clique/expansão** (mobile no drawer do sidebar); **permanecem visíveis** quando a URL atual é a de um filtro salvo; **somem** fora de Municípios e sem hover/expansão.
|
||||
## Achados de acessibilidade corrigidos no critique
|
||||
|
||||
FD2 ([field-desk-ux-pos-critique.md](field-desk-ux-pos-critique.md)) adiava “saved views genéricas”; este item é a fatia **concreta e única** (só lista de municípios), não um framework de views.
|
||||
- **SC 2.5.3 (Label in Name):** o botão mostrava "Salvar filtro" e tinha `aria-label="Salvar este filtro"`. O nome acessível precisa **conter** o rótulo visível, ou "clique em Salvar filtro" por voz não casa com nada. O `aria-label` saiu do caso de salvar; o de renomear fica, porque mantém a palavra visível na frente.
|
||||
- A mensagem de recusa ganhou `aria-describedby` do input além do `role="alert"` que o `Alert` já traz: o alerta anuncia uma vez, o `describedby` é o que conta a quem volta ao campo **por que** ele ainda está inválido.
|
||||
- O `<ul>` do submenu ganhou nome (`aria-label="Filtros salvos de Municípios"`), e os links ativos ganharam `aria-current="page"` — inclusive os de 1º nível, que não tinham.
|
||||
- Apagar é revelado no hover/foco no desktop com `opacity-0` (que continua focável e no a11y tree, ao contrário de `hidden`) e é **permanentemente visível no toque**.
|
||||
|
||||
## Objetivos
|
||||
## Não escopo (mantido)
|
||||
|
||||
- Staff em `/campanha/municipios`: affordance **Salvar filtro** (nome obrigatório) que grava o estado canônico atual da URL (sem `page`, sem `compare`).
|
||||
- Lista de filtros salvos no **sidebar**, indentada sob **Municípios**, cada um linkando ao href canônico; item ativo quando a URL atual casa com o href salvo.
|
||||
- Visibilidade do submenu:
|
||||
- **mostrar** no hover do item Municípios (desktop);
|
||||
- **mostrar** ao expandir Municípios no drawer mobile (clique);
|
||||
- **mostrar (sticky)** quando a página atual é um filtro salvo (match de href);
|
||||
- **esconder** em rotas que não são de municípios **e** sem hover/expansão.
|
||||
- CRUD mínimo: criar + abrir + apagar (renomear = questão em aberto).
|
||||
- Guardrails: sem migration, sem collection, sem Consent, sem server action de escrita; `leader` sem a página; access/loader inalterados; Feel the action no Salvar (pending no controle) e na navegação do atalho (`CampaignListPending` já cobre a lista).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Item de trilha B18 (não fill-in; não fase de B16/B17; não absorver em Visitados).** Job distinto: bookmark **nomeado e intencional** vs histórico automático (Visitados) vs viewport de colunas (B17) vs relocação de selects (B16). Appetite ~1–1,5d; cortável. (2026-07-24, roadmap-item.) **Rejeitado:** fill-in só (subestima o contrato de submenu sticky/hover); estender `recentVisits` com “pin” (mistura dwell automático com nomeação); saved views genéricas multi-lista (FD2 já vetou; explode escopo).
|
||||
- **Persistência = `localStorage` (não servidor).** Preferência/atalho por dispositivo; mesmo racional de Visitados e B17. Key estável namespaced (ex. `teqo:campaign:municipality-saved-filters`). **Rejeitado:** collection `savedFilter` / campo em `campaignUser` (migration + access + sync sem evidência de multi-device); `sessionStorage` (perde no ritual diário); cookie server-side.
|
||||
- **Estado salvo = subset da URL da lista**, canonicalizado via `municipalityUi` (reusar `parseMunicipalityListParams` + href page=1): `q`, `region`, `kind`, `coverage`, `priority`, `trend`, `sort`, `dir`. **Excluir** `page` (sempre reabre na 1) e `compare` (lente do mapa no Início, não da lista). **Excluir** cenário A10 (fora da URL; lente local — alinhado ao fill-in Cenário / decisão A10). **Rejeitado:** gravar `page`/`compare`/cenário; gravar snapshot de IDs de municípios (vira lista morta).
|
||||
- **Identidade:** `id` client-side (UUID) + `name` (pt-BR, trim, length cap) + `href` canônico (+ opcional `savedAt`). Active = comparação de href canônico com a URL atual (pathname `/campanha/municipios` + search normalizado). **Rejeitado:** rota dedicada `/campanha/municipios/vistas/[id]` (URL nova cara; share pior); dedup só por nome (dois nomes iguais OK se href diferente).
|
||||
- **UI de salvar = submit explícito com nome** (Dialog/Popover + input + confirmar). Exceção justa ao princípio “Auto-save, no Save button”: nomear é confirmatório. **Rejeitado:** auto-save silencioso a cada mudança de filtro (vira Visitados); prompt nativo `window.prompt`.
|
||||
- **Sidebar = 2º nível sob Municípios** com `SidebarMenuSub*` existente; hover (desktop) / expand (mobile); sticky só no match de filtro salvo — **não** sticky em `/campanha/municipios` genérico nem em `/campanha/municipios/[slug]`. Bottom nav mobile permanece flat (atalhos vivem no drawer do sidebar). **Rejeitado:** sempre listar salvos sob Municípios (polui); grupo separado no rodapé do sidebar; chips no dashboard.
|
||||
- **Limite + logout:** `MAX_ENTRIES` curto (recomendação 12); `clearMunicipalitySavedFilters()` no logout junto com `clearRecentVisits()` (dispositivo compartilhado). **Rejeitado:** ilimitado; sync cross-device.
|
||||
- **i18n e naming:** `MunicipalitySavedFilter`, `municipalitySavedFilters.ts`, `SaveMunicipalityFilterControl`, `MunicipalityNavSavedFilters`; strings “Salvar filtro”, “Filtros salvos”, “Apagar”; identificadores em inglês.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Permitir salvar a lista “nua” (sem filtros, só sort default)?** **Opções:** A) só com ao menos um param de recorte (`q`/`region`/…) | B) qualquer estado, inclusive nua | C) nua só se `sort`/`dir` ≠ default. **Recomendação:** **A** — alinhado a Visitados (`buildMunicipalityListVisitLabel` retorna null sem filtro); atalho “Municípios” já cobre a lista nua. _(assumido — validar com produto)_
|
||||
- **Renomear no v1?** **Opções:** A) só criar/apagar | B) renomear no submenu (inline/Dialog). **Recomendação:** **A** — apagar+recriar basta no appetite; renomear = Adiado. _(assumido)_
|
||||
- **Onde mora o botão Salvar?** **Opções:** A) barra slim de `MunicipalityFilters` (ao lado de Limpar / Colunas) | B) menu “…” da lista | C) só no overview. **Recomendação:** **A** — mesmo lugar mental dos filtros; soft com B16/B17. _(assumido)_
|
||||
- **Dois salvos com o mesmo href: atualizar o existente ou criar duplicata?** **Opções:** A) upsert (pede confirmação se nome diferente) | B) sempre criar | C) bloquear com mensagem. **Recomendação:** **A** — um atalho por href; evita submenu duplicado. _(assumido)_
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
List["/campanha/municipios?…\nMunicipalityListState"]
|
||||
Save["SaveMunicipalityFilterControl\nnome + confirmar"]
|
||||
Store["municipalitySavedFilters.ts\nlocalStorage"]
|
||||
Nav["CampaignSidebar\nMunicípios + SidebarMenuSub"]
|
||||
Match["href canônico == URL atual\n→ sticky open + active"]
|
||||
|
||||
List --> Save
|
||||
Save --> Store
|
||||
Store --> Nav
|
||||
List --> Match
|
||||
Match --> Nav
|
||||
Nav -->|Link| List
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/utilities/municipalitySavedFilters.ts`** (client-safe, guarda `typeof window`): `STORAGE_KEY`, `MAX_ENTRIES`, tipos, `list` / `upsert` / `remove` / `clear`; validação de shape; no-op fora do browser. Depth: espelhar `recentVisits.ts`, não inventar service genérico de “prefs”.
|
||||
- **Helpers em `municipalityUi.ts`:** `buildMunicipalitySavedFilterHref(state)` (= visit href / page=1 sem compare) + `municipalityListHasSavableFilters(state)` (gate do botão). Reusar parse/canonical já existentes.
|
||||
- **`SaveMunicipalityFilterControl`** (client, em `src/components/campaign/`): Popover/Dialog com nome; desabilitado se não há filtro salvável ou se já é o ativo sem mudanças; pending imediato no confirmar; toast/inline “Salvo” curto; montar na barra slim (`MunicipalityFilters` pós-B16, ou fileira atual se B16 ainda não aterrissou).
|
||||
- **`MunicipalityNavSavedFilters`** (client island no `CampaignSidebar`): lê storage pós-mount (sem mismatch de hidratação); renderiza `SidebarMenuSub` sob o item Municípios; hover/focus-within (desktop) + estado expandido (mobile); `forceOpen` quando `usePathname`+`useSearchParams` casam com algum `href` salvo; cada linha = `SidebarMenuSubButton asChild` + `Link` + ação apagar (`SidebarMenuAction` ou botão sr-only no hover); fechar drawer mobile ao navegar (mesmo padrão atual).
|
||||
- **`CampaignSidebar` / item Municípios:** especializar só o item `href === '/campanha/municipios'` (não mudar `nav.ts` para árvore genérica — YAGNI <3 call sites). `isCampaignNavActive` do pai continua por pathname; sub-item ativo por match de search.
|
||||
- **Logout:** chamar `clearMunicipalitySavedFilters()` ao lado de `clearRecentVisits()`.
|
||||
- **Sem migration, sem collection, sem server action, sem Consent.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- Soft: **B16** (barra slim = destino natural do Salvar) e **B17** (vizinho na mesma barra) — não bloqueiam; sem eles, Salvar pousa ao lado de Limpar na fileira atual.
|
||||
- Soft: precedente Visitados ([visitados-recentemente.md](visitados-recentemente.md)) — padrão storage/logout.
|
||||
- Soft: **B38** ([sidebar-recolhido-tablet.md](sidebar-recolhido-tablet.md)) — com `collapsible="offcanvas"`, o submenu hover/expand sob Municípios só é usável com o rail expandido (ou no Sheet mobile); se B38 landar antes, o craft do B18 trata collapsed como “abrir rail”.
|
||||
- Nenhuma dependência dura de outro plano. Reusa `municipalityUi.ts`, `CampaignSidebar`, `SidebarMenuSub*`, `CampaignListPendingBoundary`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Sync multi-device / preferências em `campaignUser` — exigiria migration + access.
|
||||
- Saved views em Planos / Apoiadores / Demandas — FD2 “genéricas”; só Municípios aqui.
|
||||
- Sticky do submenu em qualquer rota `/campanha/municipios*` — só match de filtro salvo.
|
||||
- Incluir Cenário A10 ou `compare` do mapa no bookmark.
|
||||
- Substituir Visitados recentemente / B14 município mais próximo.
|
||||
- Reorder DnD de colunas — [fora de escopo](reordenar-colunas-lista-municipios.md).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Framework de “nav tree” / collapsible genérico no `nav.ts`.** Se alguém “só preparar” para outros itens: explode o shell. **Mitigação:** especializar só Municípios neste item; 3º call site = gatilho de abstrair.
|
||||
- **Rota `/vistas/[id]` + loader server.** Se alguém “só para deep-link nomeado”: migration mental + share frágil. **Mitigação:** href = query canônica da lista.
|
||||
- **Optimistic list sem refresh ao abrir atalho.** Anti-goal Feel the action. **Mitigação:** `Link` + pending da região de resultados existente.
|
||||
- **Spreadsheet / painel de gestão de views.** **Mitigação:** criar/apagar no fluxo; sem página admin de views.
|
||||
Sync multi-device / preferências em `campaignUser`; saved views em outras listas (FD2 já vetou o genérico); rota `/vistas/[id]`; incluir Cenário A10 ou `compare` do mapa no bookmark; salvar junto o viewport de colunas do B17.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Renomear filtro salvo.** Revisitar quando: ≥2 atores pedirem em R6/sessão **ou** apagar+recriar gerar atrito medido.
|
||||
- **Sync servidor / share de view entre assessores.** Revisitar quando: evidência de multi-device **e** pedido explícito de “mandar este recorte” (aí sim collection + access; não neste appetite).
|
||||
- **Salvar também o viewport B17 (colunas visíveis) junto do filtro.** Revisitar quando: B17 estável **e** usuários pedirem “a view completa” (hoje misturaria bookmark de decisão com preferência de densidade).
|
||||
- **Árvore de navegação genérica em `nav.ts`.** Este item especializa **só** Municípios, via a constante exportada `MUNICIPALITY_NAV_HREF`. **Gatilho:** o 3º call site de submenu no sidebar.
|
||||
- **Sync servidor / compartilhar recorte entre assessores.** **Gatilho:** evidência de multi-device **e** pedido explícito de "mandar este recorte" — aí sim collection + access, fora deste appetite.
|
||||
|
||||
## Testes
|
||||
|
||||
- `tests/unit/listQueryMatch.unit.spec.ts` — ordem trocada casa, `page` diferente casa, filtro a mais não casa, percent-encoding normaliza.
|
||||
- `tests/unit/municipalitySavedFilters.unit.spec.ts` — upsert por href, cap, shape inválido, dedup, cache de snapshot.
|
||||
- `tests/e2e/campaignSavedFilters.e2e.spec.ts` — filtrar → salvar → submenu → ativo na página 2 → restaurar → renomear → apagar → desfazer, e o disclosure persistido através de reload.
|
||||
|
||||
**Nota operacional:** `pnpm build` limpa `.next`, e o servidor de dev do e2e usa `NEXT_DIST_DIR=.next/e2e` — rodar o build entre duas rodadas de e2e derruba o cache de compilação e faz o projeto `setup` estourar os 90 s. Não é flake do teste.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Demais itens abertos · grafo · Janela 1–2 · cortes)
|
||||
- `docs/plans/visitados-recentemente.md` — precedente localStorage + logout
|
||||
- `docs/plans/filtros-no-header-lista-municipios.md` (B16) · `docs/plans/seletor-colunas-lista-municipios.md` (B17)
|
||||
- `docs/plans/field-desk-ux-pos-critique.md` — “saved views genéricas” adiadas; este item é a fatia concreta
|
||||
- `src/utilities/municipalityUi.ts` — estado URL / href canônico / visit label
|
||||
- `docs/plans/visitados-recentemente.md` — precedente de localStorage + logout
|
||||
- `docs/plans/seletor-colunas-lista-municipios.md` (B17) — vizinho que persiste em **cookie**, porque o servidor precisa ler
|
||||
- `src/utilities/municipalityListUrl.ts` / `municipalityListFilters.ts` — contrato de URL congelado
|
||||
- `src/utilities/recentVisits.ts` — espelho de storage
|
||||
- `src/components/campaign/CampaignSidebar.tsx`, `nav.ts`, `MunicipalityFilters.tsx`
|
||||
- `src/components/ui/Sidebar.tsx` — `SidebarMenuSub*`
|
||||
- AGENTS.md — naming EN / copy pt-BR; campaign auth; sem Consent para preferência local do próprio usuário
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk; Feel the action; Auto-save (exceção nomear)
|
||||
- `src/components/ui/Sidebar.tsx` — `SidebarMenuSub*` / `SidebarMenuAction` re-adicionados
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl'
|
||||
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
|
||||
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
|
|
@ -80,6 +81,7 @@ export const MunicipalityFilters = ({
|
|||
{activeFiltersSummary}
|
||||
</p>
|
||||
) : null}
|
||||
<SaveMunicipalityFilterControl state={state} />
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
'use client'
|
||||
|
||||
import { BookmarkPlusIcon } from 'lucide-react'
|
||||
import { useId, useState, type FormEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useMunicipalitySavedFilters } from '@/components/campaign/shared/useMunicipalitySavedFilters'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import {
|
||||
buildMunicipalitySavedFilterHref,
|
||||
formatMunicipalityActiveFiltersSummary,
|
||||
} from '@/utilities/municipalityListFilters'
|
||||
import type { MunicipalityListState } from '@/utilities/municipalityListUrl'
|
||||
import {
|
||||
MAX_ENTRIES,
|
||||
MAX_NAME_LENGTH,
|
||||
saveMunicipalitySavedFilter,
|
||||
} from '@/utilities/municipalitySavedFilters'
|
||||
|
||||
type SaveMunicipalityFilterControlProps = {
|
||||
/** The APPLIED state — a search still inside the debounce window is not in the URL yet. */
|
||||
state: MunicipalityListState
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the current recorte and keeps it in the sidebar (B18).
|
||||
*
|
||||
* Naming is confirmatory, so this is the explicit-submit exception
|
||||
* `.cursor/rules/campanha-edit-where-you-see.mdc` already carves out for flows
|
||||
* that need a note — and it keeps this control out of the auto-save machine the
|
||||
* quick-edit cells share.
|
||||
*/
|
||||
export const SaveMunicipalityFilterControl = ({ state }: SaveMunicipalityFilterControlProps) => {
|
||||
const savedFilters = useMunicipalitySavedFilters()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const nameId = useId()
|
||||
const errorId = `${nameId}-error`
|
||||
|
||||
const summary = formatMunicipalityActiveFiltersSummary(state)
|
||||
const href = buildMunicipalitySavedFilterHref(state)
|
||||
const existing = savedFilters.find((entry) => entry.href === href)
|
||||
|
||||
// The bare list has no recorte to name. This is the same gate the `Limpar`
|
||||
// button uses, minus the draft search: only what the URL already holds can be
|
||||
// bookmarked, or the name would describe a filter the href does not carry.
|
||||
if (!summary) return null
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (!next) return
|
||||
setName(existing?.name ?? summary.slice(0, MAX_NAME_LENGTH))
|
||||
setErrorMessage('')
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setErrorMessage('Dê um nome ao filtro.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = saveMunicipalitySavedFilter({ href, name })
|
||||
if (result === 'limit') {
|
||||
setErrorMessage(
|
||||
`Você já tem ${MAX_ENTRIES} filtros salvos. Apague um em Municípios, na navegação, para salvar outro.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (result === 'failed') {
|
||||
setErrorMessage('Não foi possível salvar neste dispositivo. Tente de novo.')
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
toast.success(existing ? 'Nome atualizado.' : 'Filtro salvo em Municípios, na navegação.')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 shrink-0 md:self-end"
|
||||
// SC 2.5.3: an accessible name has to CONTAIN the visible label, or
|
||||
// "clique em Salvar filtro" matches nothing by voice. The rename case
|
||||
// may add the target's name because it keeps the visible word first.
|
||||
aria-label={existing ? `Renomear o filtro salvo ${existing.name}` : undefined}
|
||||
>
|
||||
<BookmarkPlusIcon aria-hidden="true" />
|
||||
{existing ? 'Renomear' : 'Salvar filtro'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Radix gives the content `role="dialog"` and no name of its own. */}
|
||||
<PopoverContent align="end" aria-label="Salvar filtro" className="w-80">
|
||||
{/* Portaled out of the search form above, so this nests no form. */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={nameId}>Nome do filtro</FieldLabel>
|
||||
<Input
|
||||
id={nameId}
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value)
|
||||
setErrorMessage('')
|
||||
}}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="min-h-11"
|
||||
aria-invalid={errorMessage ? true : undefined}
|
||||
// `role="alert"` announces the refusal once; this is what tells a
|
||||
// reader returning to the field WHY it is still invalid.
|
||||
aria-describedby={errorMessage ? errorId : undefined}
|
||||
/>
|
||||
<FieldDescription>{summary}</FieldDescription>
|
||||
</Field>
|
||||
{errorMessage ? (
|
||||
<Alert variant="destructive" id={errorId}>
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" className="min-h-11 w-full">
|
||||
{existing ? 'Atualizar nome' : 'Salvar'}
|
||||
</Button>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
'use client'
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import {
|
||||
listMunicipalitySavedFilters,
|
||||
subscribeMunicipalitySavedFilters,
|
||||
type MunicipalitySavedFilter,
|
||||
} from '@/utilities/municipalitySavedFilters'
|
||||
|
||||
/**
|
||||
* Stable reference for the render that has no `localStorage` to read. Returning
|
||||
* a fresh `[]` here is the classic `useSyncExternalStore` render loop.
|
||||
*/
|
||||
const NO_SAVED_FILTERS: readonly MunicipalitySavedFilter[] = []
|
||||
|
||||
/**
|
||||
* The filter bar writes this store and the sidebar reads it, from trees that
|
||||
* never meet — so both subscribe rather than lift state, and one write repaints
|
||||
* both. Shared because the snapshot/subscribe pairing is exactly the thing that
|
||||
* must not be written twice.
|
||||
*/
|
||||
export const useMunicipalitySavedFilters = (): readonly MunicipalitySavedFilter[] =>
|
||||
useSyncExternalStore(
|
||||
subscribeMunicipalitySavedFilters,
|
||||
listMunicipalitySavedFilters,
|
||||
() => NO_SAVED_FILTERS,
|
||||
)
|
||||
|
|
@ -2,16 +2,18 @@
|
|||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useState, type FormEvent, type ReactNode } from 'react'
|
||||
|
||||
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||||
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
|
||||
import { CampaignLogo } from '@/components/campaign/shell/campaign-logo'
|
||||
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
|
||||
import {
|
||||
getCampaignNav,
|
||||
getCampaignSecondaryNav,
|
||||
isCampaignNavActive,
|
||||
MUNICIPALITY_NAV_HREF,
|
||||
type CampaignNavItem,
|
||||
} from '@/components/campaign/shell/nav'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -31,6 +33,7 @@ import { Spinner } from '@/components/ui/Spinner'
|
|||
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
|
||||
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
|
||||
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
|
||||
import { clearMunicipalitySavedFilters } from '@/utilities/municipalitySavedFilters'
|
||||
import { clearRecentVisits } from '@/utilities/recentVisits'
|
||||
|
||||
export type CampaignSidebarUser = CampaignUserShellView
|
||||
|
|
@ -39,18 +42,22 @@ const CampaignSidebarLink = ({
|
|||
item,
|
||||
isActive,
|
||||
onNavigate,
|
||||
children,
|
||||
}: {
|
||||
item: CampaignNavItem
|
||||
isActive: boolean
|
||||
onNavigate: () => void
|
||||
/** Row affordances that sit beside the link — B18's disclosure and sub-list. */
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.href} onClick={onNavigate}>
|
||||
<Link href={item.href} onClick={onNavigate} aria-current={isActive ? 'page' : undefined}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{children}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
|
||||
|
|
@ -69,6 +76,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
if (isLoggingOut) return
|
||||
setIsLoggingOut(true)
|
||||
clearRecentVisits()
|
||||
clearMunicipalitySavedFilters()
|
||||
await clearCampaignPwaCaches()
|
||||
// logoutCampaign redirects — no need to reset the pending flag on success.
|
||||
await logoutCampaign()
|
||||
|
|
@ -98,7 +106,11 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
item={item}
|
||||
isActive={isCampaignNavActive(pathname, item.href)}
|
||||
onNavigate={closeMobileSidebar}
|
||||
/>
|
||||
>
|
||||
{item.href === MUNICIPALITY_NAV_HREF ? (
|
||||
<MunicipalityNavSavedFilters onNavigate={closeMobileSidebar} />
|
||||
) : null}
|
||||
</CampaignSidebarLink>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
|
|
|||
151
src/components/campaign/shell/MunicipalityNavSavedFilters.tsx
Normal file
151
src/components/campaign/shell/MunicipalityNavSavedFilters.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronRightIcon, Trash2Icon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useMunicipalitySavedFilters } from '@/components/campaign/shared/useMunicipalitySavedFilters'
|
||||
import {
|
||||
SidebarMenuAction,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@/components/ui/Sidebar'
|
||||
import { isSameListHref } from '@/lib/listQueryMatch'
|
||||
import {
|
||||
readMunicipalitySavedFiltersOpen,
|
||||
removeMunicipalitySavedFilter,
|
||||
saveMunicipalitySavedFilter,
|
||||
writeMunicipalitySavedFiltersOpen,
|
||||
type MunicipalitySavedFilter,
|
||||
} from '@/utilities/municipalitySavedFilters'
|
||||
|
||||
/** A page is a position inside a recorte, not part of it. */
|
||||
const IGNORED_PARAMS = ['page']
|
||||
|
||||
/**
|
||||
* The saved filters of `/campanha/municipios` (B18), hung under that nav item.
|
||||
*
|
||||
* Renders only the disclosure and the sub-list: the nav link above it stays the
|
||||
* one `CampaignSidebarLink` every other item uses.
|
||||
*/
|
||||
export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () => void }) => {
|
||||
const savedFilters = useMunicipalitySavedFilters()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
// Read once, not tracked: the store owns the list, this is only the
|
||||
// disclosure, and the toggle already writes it back.
|
||||
const [open, setOpen] = useState(readMunicipalitySavedFiltersOpen)
|
||||
const listId = useId()
|
||||
const disclosureRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const query = searchParams.toString()
|
||||
const currentHref = query ? `${pathname}?${query}` : pathname
|
||||
const activeHref = savedFilters.find((entry) =>
|
||||
isSameListHref(currentHref, entry.href, IGNORED_PARAMS),
|
||||
)?.href
|
||||
|
||||
// Arriving at a saved filter reveals where you are, but only on a real
|
||||
// navigation into one: opening on every mount would make the persisted
|
||||
// preference unhonorable for whoever uses saved filters most, since they
|
||||
// collapse the group, reload, and find it back. The trigger is the URL
|
||||
// changing rather than `activeHref` appearing, because the store answers
|
||||
// empty until the client has read `localStorage` — a match surfacing out of
|
||||
// hydration would otherwise read as a navigation on every single load.
|
||||
const previousHref = useRef(currentHref)
|
||||
useEffect(() => {
|
||||
const navigated = previousHref.current !== currentHref
|
||||
previousHref.current = currentHref
|
||||
if (navigated && activeHref) setOpen(true)
|
||||
}, [currentHref, activeHref])
|
||||
|
||||
// No group until there is something in it — and nothing to hydrate either,
|
||||
// since the store answers empty until the client reads `localStorage`.
|
||||
if (!savedFilters.length) return null
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
writeMunicipalitySavedFiltersOpen(next)
|
||||
}
|
||||
|
||||
const handleRemove = (entry: MunicipalitySavedFilter) => {
|
||||
// The button about to vanish is the one holding focus, which would drop to
|
||||
// <body> and strand a keyboard user at the top of the page. Resolve the
|
||||
// survivor BEFORE the removal: emptying the group unmounts this whole
|
||||
// component, disclosure included, so the fallback has to be the nav link.
|
||||
const disclosure = disclosureRef.current
|
||||
const successor =
|
||||
savedFilters.length > 1
|
||||
? disclosure
|
||||
: disclosure?.closest('li')?.querySelector<HTMLElement>('a')
|
||||
|
||||
removeMunicipalitySavedFilter(entry.href)
|
||||
successor?.focus()
|
||||
|
||||
// Undo instead of a confirmation: the entry restores to the same name and
|
||||
// the same alphabetical slot, so the cheap path is reversal, not a prompt.
|
||||
toast.success(`Filtro “${entry.name}” apagado.`, {
|
||||
action: {
|
||||
label: 'Desfazer',
|
||||
onClick: () => {
|
||||
if (saveMunicipalitySavedFilter(entry) !== 'saved') {
|
||||
toast.error('Não foi possível restaurar o filtro.')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenuAction
|
||||
ref={disclosureRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={`${open ? 'Ocultar' : 'Mostrar'} os filtros salvos de Municípios`}
|
||||
onClick={toggle}
|
||||
className={open ? 'rotate-90' : undefined}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden="true" />
|
||||
</SidebarMenuAction>
|
||||
{/* Always mounted so `aria-controls` always resolves; `hidden` wins over
|
||||
the list's own `flex` through tailwind-merge. */}
|
||||
<SidebarMenuSub
|
||||
id={listId}
|
||||
aria-label="Filtros salvos de Municípios"
|
||||
className={open ? undefined : 'hidden'}
|
||||
>
|
||||
{savedFilters.map((entry) => {
|
||||
const isActive = entry.href === activeHref
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem key={entry.href}>
|
||||
<SidebarMenuSubButton asChild isActive={isActive}>
|
||||
<Link
|
||||
href={entry.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={entry.name}
|
||||
>
|
||||
<span>{entry.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
<SidebarMenuAction
|
||||
type="button"
|
||||
onClick={() => handleRemove(entry)}
|
||||
aria-label={`Apagar o filtro salvo ${entry.name}`}
|
||||
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,9 +22,16 @@ export type CampaignNavItem = {
|
|||
icon: LucideIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* Named because the sidebar has to recognize this one item to hang B18's saved
|
||||
* filters under it, and a third spelling that silently no-ops when it drifts is
|
||||
* the failure mode this codebase keeps paying for.
|
||||
*/
|
||||
export const MUNICIPALITY_NAV_HREF = '/campanha/municipios'
|
||||
|
||||
const staffNav: CampaignNavItem[] = [
|
||||
{ title: 'Início', href: '/campanha', icon: HomeIcon },
|
||||
{ title: 'Municípios', href: '/campanha/municipios', icon: MapPinIcon },
|
||||
{ title: 'Municípios', href: MUNICIPALITY_NAV_HREF, icon: MapPinIcon },
|
||||
{ title: 'Territórios', href: '/campanha/territorios', icon: MapIcon },
|
||||
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
|
||||
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
|
||||
|
|
|
|||
|
|
@ -391,7 +391,10 @@ const SidebarMenuButton = ({
|
|||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
// Absent, not `"false"`: Tailwind compiles `data-active:` to the bare
|
||||
// `[data-active]`, and React renders `data-active={false}` as the string
|
||||
// `"false"` — which matches it. Every row was wearing the active accent.
|
||||
data-active={isActive || undefined}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -420,6 +423,90 @@ const SidebarMenuButton = ({
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-added from upstream shadcn for B18 (the kit had been pruned of all four
|
||||
* submenu pieces). Two deliberate deviations, both load-bearing:
|
||||
*
|
||||
* - upstream's `showOnHover` prop is not here — the only caller reveals its
|
||||
* action against the SUB-item group, and `group-hover/menu-item` would leak
|
||||
* from the parent row, revealing every child's action at once;
|
||||
* - the mobile hit-area expansion is not `md:`-gated. A 20 px visible target on
|
||||
* a pointer device is still a 20 px target (SC 2.5.8 has no mouse exemption),
|
||||
* which is the call B34 already made for the chip "×".
|
||||
*/
|
||||
const SidebarMenuAction = ({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean
|
||||
}) => {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform after:absolute after:-inset-2 peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuSub = ({ className, ...props }: React.ComponentProps<'ul'>) => (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
'mx-3.5 flex min-w-0 translate-x-px list-none flex-col gap-0 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const SidebarMenuSubItem = ({ className, ...props }: React.ComponentProps<'li'>) => (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn('group/menu-sub-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const SidebarMenuSubButton = ({
|
||||
asChild = false,
|
||||
size = 'md',
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
isActive?: boolean
|
||||
}) => {
|
||||
const Comp = asChild ? Slot.Root : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive || undefined}
|
||||
className={cn(
|
||||
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-has-data-[sidebar=menu-action]/menu-sub-item:pr-8 group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -429,8 +516,12 @@ export {
|
|||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
|
|
|
|||
46
src/lib/listQueryMatch.ts
Normal file
46
src/lib/listQueryMatch.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Whether two list hrefs point at the same recorte.
|
||||
*
|
||||
* This exists as string comparison rather than a re-serialization through the
|
||||
* domain's canonical builder because its caller is the campaign sidebar, which
|
||||
* lives in the `(app)` layout and therefore ships on the First Load JS of every
|
||||
* `/campanha` route: importing `buildMunicipalityListHref` there would drag
|
||||
* `bahiaTerritories` + `municipalityCatalog` along with it (~21 kB, measured in
|
||||
* B14).
|
||||
*/
|
||||
|
||||
const HREF_BASE = 'https://list-query-match.invalid'
|
||||
|
||||
const parseHref = (href: string): { pathname: string; search: URLSearchParams } | null => {
|
||||
try {
|
||||
// A base makes relative hrefs parseable and normalizes percent-encoding on
|
||||
// both sides, so `?q=ita%C3%BAna` and `?q=itaúna` are the same recorte.
|
||||
const url = new URL(href, HREF_BASE)
|
||||
return { pathname: url.pathname, search: url.searchParams }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const queryKey = (search: URLSearchParams, ignoredParams: readonly string[]): string => {
|
||||
const pairs: string[] = []
|
||||
for (const [name, value] of search) {
|
||||
if (ignoredParams.includes(name)) continue
|
||||
pairs.push(`${name}=${value}`)
|
||||
}
|
||||
// Sorted so param order never decides the answer, and repeated params
|
||||
// (`?region=A®ion=B`) compare as the set they encode.
|
||||
return pairs.sort().join('&')
|
||||
}
|
||||
|
||||
export const isSameListHref = (
|
||||
left: string,
|
||||
right: string,
|
||||
ignoredParams: readonly string[] = [],
|
||||
): boolean => {
|
||||
const parsedLeft = parseHref(left)
|
||||
const parsedRight = parseHref(right)
|
||||
if (!parsedLeft || !parsedRight) return false
|
||||
if (parsedLeft.pathname !== parsedRight.pathname) return false
|
||||
return queryKey(parsedLeft.search, ignoredParams) === queryKey(parsedRight.search, ignoredParams)
|
||||
}
|
||||
|
|
@ -407,3 +407,11 @@ export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): s
|
|||
|
||||
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>
|
||||
buildMunicipalityListHref(state, 1)
|
||||
|
||||
/**
|
||||
* The href a saved filter (B18) bookmarks: the visit href minus `compare`, which
|
||||
* is the Início map's comparison lens rather than a recorte of this list, and
|
||||
* which the list's param set only carries so a hand-typed URL survives.
|
||||
*/
|
||||
export const buildMunicipalitySavedFilterHref = (state: MunicipalityListState): string =>
|
||||
buildMunicipalityListHref({ ...state, compare: undefined }, 1)
|
||||
|
|
|
|||
177
src/utilities/municipalitySavedFilters.ts
Normal file
177
src/utilities/municipalitySavedFilters.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Named bookmarks of the municipality list URL (B18).
|
||||
*
|
||||
* Device-local on purpose: a nav shortcut never has to reach the RSC render, so
|
||||
* this is `localStorage` — the same call `recentVisits.ts` made, and unlike
|
||||
* B17's column cookie, which the server reads to filter columns.
|
||||
*
|
||||
* The canonical href IS the identity. One shortcut per recorte, so re-saving a
|
||||
* slice that is already stored renames it instead of growing a second submenu
|
||||
* row that navigates to the same place.
|
||||
*/
|
||||
|
||||
export const STORAGE_KEY = 'teqo:campaign:municipality-saved-filters'
|
||||
|
||||
/**
|
||||
* A named bookmark is not a history entry: at the cap the save is refused with
|
||||
* a reason instead of silently evicting something the user deliberately named
|
||||
* (which is what `recentVisits`'s `slice` does, correctly, for dwell history).
|
||||
*/
|
||||
export const MAX_ENTRIES = 12
|
||||
|
||||
export const MAX_NAME_LENGTH = 60
|
||||
|
||||
/**
|
||||
* Two islands share this store — the filter bar writes it, the sidebar reads it
|
||||
* — and they never share a React tree, so a write has to announce itself. The
|
||||
* native `storage` event covers other tabs and deliberately does not fire in the
|
||||
* tab that wrote, which is exactly the half this event adds.
|
||||
*/
|
||||
const CHANGE_EVENT = 'teqo:campaign:municipality-saved-filters-change'
|
||||
|
||||
export type MunicipalitySavedFilter = {
|
||||
/** Canonical list href (page 1, no `compare`) — also the entry's identity. */
|
||||
href: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SaveMunicipalitySavedFilterResult = 'saved' | 'limit' | 'failed'
|
||||
|
||||
const EMPTY: readonly MunicipalitySavedFilter[] = Object.freeze([])
|
||||
|
||||
/**
|
||||
* `useSyncExternalStore` compares snapshots by reference, so parsing storage on
|
||||
* every `getSnapshot` call would render forever. The cache is dropped by every
|
||||
* write and by a `storage` event from another tab.
|
||||
*/
|
||||
let snapshot: readonly MunicipalitySavedFilter[] | null = null
|
||||
|
||||
const isMunicipalitySavedFilter = (value: unknown): value is MunicipalitySavedFilter => {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const entry = value as Record<string, unknown>
|
||||
return (
|
||||
typeof entry.href === 'string' &&
|
||||
entry.href.startsWith('/campanha/municipios') &&
|
||||
typeof entry.name === 'string' &&
|
||||
entry.name.trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alphabetical, not by recency: a named shortcut that reorders itself under the
|
||||
* cursor every time it is re-saved is a nav bug. Recency is Visitados' job.
|
||||
*/
|
||||
const byName = (left: MunicipalitySavedFilter, right: MunicipalitySavedFilter): number =>
|
||||
left.name.localeCompare(right.name, 'pt-BR')
|
||||
|
||||
const read = (): readonly MunicipalitySavedFilter[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return EMPTY
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return EMPTY
|
||||
const seen = new Set<string>()
|
||||
const entries = parsed.filter(isMunicipalitySavedFilter).filter((entry) => {
|
||||
if (seen.has(entry.href)) return false
|
||||
seen.add(entry.href)
|
||||
return true
|
||||
})
|
||||
return entries.length ? entries.slice(0, MAX_ENTRIES).sort(byName) : EMPTY
|
||||
} catch {
|
||||
return EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
export const listMunicipalitySavedFilters = (): readonly MunicipalitySavedFilter[] => {
|
||||
if (typeof window === 'undefined') return EMPTY
|
||||
snapshot ??= read()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const write = (entries: readonly MunicipalitySavedFilter[]): boolean => {
|
||||
let persisted = true
|
||||
try {
|
||||
if (entries.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
||||
else localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// Quota or private mode. Readers are told to re-read either way, so the
|
||||
// submenu shows what actually persisted rather than a save that did not.
|
||||
persisted = false
|
||||
}
|
||||
snapshot = null
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT))
|
||||
return persisted
|
||||
}
|
||||
|
||||
export const saveMunicipalitySavedFilter = (
|
||||
entry: MunicipalitySavedFilter,
|
||||
): SaveMunicipalitySavedFilterResult => {
|
||||
if (typeof window === 'undefined') return 'failed'
|
||||
const name = entry.name.trim().slice(0, MAX_NAME_LENGTH)
|
||||
if (!name) return 'failed'
|
||||
const others = listMunicipalitySavedFilters().filter((item) => item.href !== entry.href)
|
||||
// Renaming at the cap has to keep working, which is why the ceiling is checked
|
||||
// against the OTHER entries and not against the stored length.
|
||||
if (others.length >= MAX_ENTRIES) return 'limit'
|
||||
return write([...others, { href: entry.href, name }].sort(byName)) ? 'saved' : 'failed'
|
||||
}
|
||||
|
||||
export const removeMunicipalitySavedFilter = (href: string): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
const existing = listMunicipalitySavedFilters()
|
||||
const next = existing.filter((item) => item.href !== href)
|
||||
if (next.length === existing.length) return
|
||||
write(next)
|
||||
}
|
||||
|
||||
export const clearMunicipalitySavedFilters = (): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(OPEN_STORAGE_KEY)
|
||||
} catch {
|
||||
// Same reason as below: the preference is a nicety.
|
||||
}
|
||||
write([])
|
||||
}
|
||||
|
||||
const OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
|
||||
|
||||
/**
|
||||
* The submenu's disclosure. Open by default — a shortcut nobody can see is a
|
||||
* shortcut nobody uses, and the group only exists once something is in it.
|
||||
*/
|
||||
export const readMunicipalitySavedFiltersOpen = (): boolean => {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
return localStorage.getItem(OPEN_STORAGE_KEY) !== 'false'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const writeMunicipalitySavedFiltersOpen = (open: boolean): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(OPEN_STORAGE_KEY, open ? 'true' : 'false')
|
||||
} catch {
|
||||
// A remembered disclosure is a nicety; a full quota must not break the
|
||||
// toggle itself, which works from React state either way.
|
||||
}
|
||||
}
|
||||
|
||||
export const subscribeMunicipalitySavedFilters = (onChange: () => void): (() => void) => {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
const handleChange = (event: Event) => {
|
||||
// `storage` fires for every key in the origin (recent visits, biometrics
|
||||
// flags); a null key means the whole store was cleared.
|
||||
if (event instanceof StorageEvent && event.key !== null && event.key !== STORAGE_KEY) return
|
||||
snapshot = null
|
||||
onChange()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, handleChange)
|
||||
window.addEventListener('storage', handleChange)
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, handleChange)
|
||||
window.removeEventListener('storage', handleChange)
|
||||
}
|
||||
}
|
||||
142
tests/e2e/campaignSavedFilters.e2e.spec.ts
Normal file
142
tests/e2e/campaignSavedFilters.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* B18 — a named recorte of `/campanha/municipios` becomes a sidebar shortcut.
|
||||
*
|
||||
* Own file for the same reason B17 took one: `campaignMunicipalities.e2e.spec.ts`
|
||||
* carries deterministic failures at one worker, ledgered as P1, and a red run
|
||||
* there must not be read as a regression here.
|
||||
*/
|
||||
|
||||
/** 416 whole municipalities — deep enough that `?page=2` is a real page. */
|
||||
const SAVED_RECORTE = '/campanha/municipios?kind=municipio'
|
||||
|
||||
const SAVED_NAME = 'Recorte do teste'
|
||||
const RENAMED = 'Recorte renomeado'
|
||||
|
||||
/**
|
||||
* A route transition on this list costs a full RSC round-trip, and in dev the
|
||||
* suite's two workers share one server — which overruns the 5 s default of
|
||||
* `expect` and produces a flake nobody reproduces at one worker. Same budget,
|
||||
* for the same reason, as `campaignColumnPicker.e2e.spec.ts`.
|
||||
*/
|
||||
const NAVIGATION = { timeout: 20_000 }
|
||||
|
||||
test.describe('Filtros salvos de Municípios', () => {
|
||||
test('saves the current recorte, restores it from the sidebar and deletes it', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
const { fixtures } = campaign
|
||||
const password = fixtures.value('senha')
|
||||
const email = `${fixtures.value('filtros-coordinator')}@example.com`
|
||||
await campaign.payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('Coordenadora Filtros'),
|
||||
email,
|
||||
password,
|
||||
role: 'coordinator',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, email, password)
|
||||
|
||||
// The bare list has no recorte to name.
|
||||
await page.goto('/campanha/municipios')
|
||||
await expect(page.getByRole('button', { name: 'Salvar filtro', exact: true })).toHaveCount(0)
|
||||
|
||||
await page.goto(SAVED_RECORTE)
|
||||
await page.getByRole('button', { name: 'Salvar filtro', exact: true }).click()
|
||||
const nameInput = page.getByLabel('Nome do filtro')
|
||||
// The suggested name is the active-filters summary the bar already shows.
|
||||
await expect(nameInput).not.toHaveValue('')
|
||||
await nameInput.fill(SAVED_NAME)
|
||||
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
|
||||
|
||||
const shortcut = page.getByRole('link', { name: SAVED_NAME })
|
||||
await expect(shortcut).toBeVisible()
|
||||
await expect(shortcut).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
// A page is a position inside the recorte, not part of it.
|
||||
await page.goto(`${SAVED_RECORTE}&page=2`)
|
||||
await expect(shortcut).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
// Another recorte is not this one, and the shortcut brings it back.
|
||||
await page.goto('/campanha/municipios')
|
||||
await expect(shortcut).not.toHaveAttribute('aria-current', 'page')
|
||||
await shortcut.click()
|
||||
await expect(page).toHaveURL(SAVED_RECORTE, NAVIGATION)
|
||||
await expect(shortcut).toHaveAttribute('aria-current', 'page', NAVIGATION)
|
||||
|
||||
// Saving a recorte already bookmarked renames it instead of adding a twin.
|
||||
await page.getByRole('button', { name: `Renomear o filtro salvo ${SAVED_NAME}` }).click()
|
||||
await expect(page.getByLabel('Nome do filtro')).toHaveValue(SAVED_NAME)
|
||||
await page.getByLabel('Nome do filtro').fill(RENAMED)
|
||||
await page.getByRole('button', { name: 'Atualizar nome' }).click()
|
||||
await expect(page.getByRole('link', { name: RENAMED })).toBeVisible()
|
||||
await expect(shortcut).toHaveCount(0)
|
||||
|
||||
const remove = page.getByRole('button', { name: `Apagar o filtro salvo ${RENAMED}` })
|
||||
await remove.click()
|
||||
await expect(page.getByRole('link', { name: RENAMED })).toHaveCount(0)
|
||||
// Emptying the group unmounts the disclosure too, so focus lands on the
|
||||
// nav link rather than falling to <body>.
|
||||
await expect(page.getByRole('link', { name: 'Municípios', exact: true })).toBeFocused()
|
||||
|
||||
// Deleting is undoable, which is why it never asks for confirmation.
|
||||
await page.getByRole('button', { name: 'Desfazer' }).click()
|
||||
await expect(page.getByRole('link', { name: RENAMED })).toBeVisible()
|
||||
})
|
||||
|
||||
test('survives a reload and hides the shortcuts behind the disclosure', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
// Three loads of a heavy list route, against a dev server shared with the
|
||||
// other worker — the login redirect alone can outrun the 30 s default.
|
||||
test.slow()
|
||||
|
||||
const { fixtures } = campaign
|
||||
const password = fixtures.value('senha')
|
||||
const email = `${fixtures.value('filtros-disclosure')}@example.com`
|
||||
await campaign.payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('Coordenadora Disclosure'),
|
||||
email,
|
||||
password,
|
||||
role: 'coordinator',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, email, password)
|
||||
await page.goto(SAVED_RECORTE)
|
||||
await page.getByRole('button', { name: 'Salvar filtro', exact: true }).click()
|
||||
await page.getByLabel('Nome do filtro').fill(SAVED_NAME)
|
||||
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
|
||||
|
||||
const shortcut = page.getByRole('link', { name: SAVED_NAME })
|
||||
await expect(shortcut).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(shortcut).toBeVisible(NAVIGATION)
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Ocultar os filtros salvos de Municípios' })
|
||||
.click(NAVIGATION)
|
||||
await expect(shortcut).toBeHidden()
|
||||
|
||||
// The collapsed disclosure is remembered across a reload.
|
||||
await page.reload()
|
||||
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeHidden(NAVIGATION)
|
||||
await page
|
||||
.getByRole('button', { name: 'Mostrar os filtros salvos de Municípios' })
|
||||
.click(NAVIGATION)
|
||||
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeVisible()
|
||||
})
|
||||
})
|
||||
65
tests/unit/listQueryMatch.unit.spec.ts
Normal file
65
tests/unit/listQueryMatch.unit.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isSameListHref } from '@/lib/listQueryMatch'
|
||||
|
||||
describe('isSameListHref', () => {
|
||||
it('ignores param order', () => {
|
||||
expect(
|
||||
isSameListHref(
|
||||
'/campanha/municipios?priority=alta®ion=Sert%C3%A3o%20Produtivo',
|
||||
'/campanha/municipios?region=Sert%C3%A3o%20Produtivo&priority=alta',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('compares repeated params as the set they encode', () => {
|
||||
expect(
|
||||
isSameListHref(
|
||||
'/campanha/municipios?level=n3&level=n4',
|
||||
'/campanha/municipios?level=n4&level=n3',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSameListHref('/campanha/municipios?level=n3&level=n4', '/campanha/municipios?level=n3'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes percent-encoding on both sides', () => {
|
||||
expect(
|
||||
isSameListHref('/campanha/municipios?q=ita%C3%BAna', '/campanha/municipios?q=itaúna'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores the params it is told to ignore, so page 3 of a saved filter still matches', () => {
|
||||
expect(
|
||||
isSameListHref(
|
||||
'/campanha/municipios?priority=alta&page=3',
|
||||
'/campanha/municipios?priority=alta',
|
||||
['page'],
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSameListHref(
|
||||
'/campanha/municipios?priority=alta&page=3',
|
||||
'/campanha/municipios?priority=alta',
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match a different recorte or a different path', () => {
|
||||
expect(
|
||||
isSameListHref(
|
||||
'/campanha/municipios?priority=alta',
|
||||
'/campanha/municipios?priority=alta&kind=zona',
|
||||
),
|
||||
).toBe(false)
|
||||
expect(isSameListHref('/campanha/municipios', '/campanha/territorios')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches the bare list against itself', () => {
|
||||
expect(isSameListHref('/campanha/municipios', '/campanha/municipios')).toBe(true)
|
||||
expect(isSameListHref('/campanha/municipios', '/campanha/municipios?page=2', ['page'])).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
134
tests/unit/municipalitySavedFilters.unit.spec.ts
Normal file
134
tests/unit/municipalitySavedFilters.unit.spec.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
clearMunicipalitySavedFilters,
|
||||
listMunicipalitySavedFilters,
|
||||
MAX_ENTRIES,
|
||||
MAX_NAME_LENGTH,
|
||||
removeMunicipalitySavedFilter,
|
||||
saveMunicipalitySavedFilter,
|
||||
STORAGE_KEY,
|
||||
} from '@/utilities/municipalitySavedFilters'
|
||||
|
||||
const hrefFor = (index: number) => `/campanha/municipios?q=recorte-${index}`
|
||||
|
||||
describe('municipalitySavedFilters storage', () => {
|
||||
afterEach(() => {
|
||||
clearMunicipalitySavedFilters()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns an empty list when storage is missing or invalid', () => {
|
||||
expect(listMunicipalitySavedFilters()).toEqual([])
|
||||
localStorage.setItem(STORAGE_KEY, 'not-json')
|
||||
expect(listMunicipalitySavedFilters()).toEqual([])
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ href: '/campanha/municipios' }))
|
||||
expect(listMunicipalitySavedFilters()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops persisted entries with a foreign href or a blank name', () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ href: '/campanha/municipios?priority=alta', name: 'Prioritárias' },
|
||||
{ href: '/campanha/liderancas?q=x', name: 'Outra lista' },
|
||||
{ href: '/campanha/municipios?kind=zona', name: ' ' },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(listMunicipalitySavedFilters()).toEqual([
|
||||
{ href: '/campanha/municipios?priority=alta', name: 'Prioritárias' },
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts alphabetically so a re-save never reorders the submenu', () => {
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Zona da Mata' })
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(2), name: 'Àgua Fria' })
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(3), name: 'Metropolitano' })
|
||||
|
||||
expect(listMunicipalitySavedFilters().map((entry) => entry.name)).toEqual([
|
||||
'Àgua Fria',
|
||||
'Metropolitano',
|
||||
'Zona da Mata',
|
||||
])
|
||||
})
|
||||
|
||||
it('renames instead of duplicating when the same recorte is saved again', () => {
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Primeiro nome' })
|
||||
expect(saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Segundo nome' })).toBe('saved')
|
||||
|
||||
expect(listMunicipalitySavedFilters()).toEqual([{ href: hrefFor(1), name: 'Segundo nome' }])
|
||||
})
|
||||
|
||||
it('trims and caps the name', () => {
|
||||
saveMunicipalitySavedFilter({
|
||||
href: hrefFor(1),
|
||||
name: ` ${'a'.repeat(MAX_NAME_LENGTH + 10)} `,
|
||||
})
|
||||
|
||||
expect(listMunicipalitySavedFilters()[0]?.name).toBe('a'.repeat(MAX_NAME_LENGTH))
|
||||
})
|
||||
|
||||
it('refuses a blank name', () => {
|
||||
expect(saveMunicipalitySavedFilter({ href: hrefFor(1), name: ' ' })).toBe('failed')
|
||||
expect(listMunicipalitySavedFilters()).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a new entry at the cap instead of evicting a named one', () => {
|
||||
for (let index = 0; index < MAX_ENTRIES; index += 1) {
|
||||
expect(saveMunicipalitySavedFilter({ href: hrefFor(index), name: `Recorte ${index}` })).toBe(
|
||||
'saved',
|
||||
)
|
||||
}
|
||||
|
||||
expect(saveMunicipalitySavedFilter({ href: hrefFor(99), name: 'Um a mais' })).toBe('limit')
|
||||
expect(listMunicipalitySavedFilters()).toHaveLength(MAX_ENTRIES)
|
||||
expect(listMunicipalitySavedFilters().some((entry) => entry.name === 'Um a mais')).toBe(false)
|
||||
})
|
||||
|
||||
it('still renames an existing entry at the cap', () => {
|
||||
for (let index = 0; index < MAX_ENTRIES; index += 1) {
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(index), name: `Recorte ${index}` })
|
||||
}
|
||||
|
||||
expect(saveMunicipalitySavedFilter({ href: hrefFor(0), name: 'Renomeado no teto' })).toBe(
|
||||
'saved',
|
||||
)
|
||||
expect(listMunicipalitySavedFilters()).toHaveLength(MAX_ENTRIES)
|
||||
expect(listMunicipalitySavedFilters().some((entry) => entry.name === 'Renomeado no teto')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a failed write when storage refuses it', () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
|
||||
expect(saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Sem espaço' })).toBe('failed')
|
||||
setItem.mockRestore()
|
||||
expect(listMunicipalitySavedFilters()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes by href and clears everything', () => {
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Um' })
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(2), name: 'Dois' })
|
||||
|
||||
removeMunicipalitySavedFilter(hrefFor(1))
|
||||
expect(listMunicipalitySavedFilters()).toEqual([{ href: hrefFor(2), name: 'Dois' }])
|
||||
|
||||
clearMunicipalitySavedFilters()
|
||||
expect(listMunicipalitySavedFilters()).toEqual([])
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('serves a stable snapshot reference until a write invalidates it', () => {
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(1), name: 'Um' })
|
||||
const first = listMunicipalitySavedFilters()
|
||||
|
||||
expect(listMunicipalitySavedFilters()).toBe(first)
|
||||
|
||||
saveMunicipalitySavedFilter({ href: hrefFor(2), name: 'Dois' })
|
||||
expect(listMunicipalitySavedFilters()).not.toBe(first)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue