diff --git a/docs/plans/barra-filtros-omnibox-listas-impl.md b/docs/plans/barra-filtros-omnibox-listas-impl.md new file mode 100644 index 00000000..1541434e --- /dev/null +++ b/docs/plans/barra-filtros-omnibox-listas-impl.md @@ -0,0 +1,108 @@ +# Impl: B127 — Barra omnibox de filtros nas listas `/campanha` + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #264 +Intenção: docs/plans/barra-filtros-omnibox-listas.md +Appetite restante: piloto Municípios + chassis compartilhado (B128 adota depois) + +## Leitura da intenção + +- **Outcome:** Uma barra omnibox (chips + caret + sugestões) é a entrada primária de filtro em Municípios em qualquer viewport; URL do recorte permanece a fonte de verdade; header filters e B18 sidebar ficam. +- **O que NÃO negociar:** contrato de URL B18; cenário fora do href/bookmark; leader lockdown; semântica inclusivo/exclusivo/texto do mapa de produto; não inventar dimensões. +- **O que reavaliar:** hipótese de “só trocar MunicipalityFilters” — precisa de chassis reutilizável (B128) + builders puros de chips/sugestões no domínio, sem terceiro caminho de estado. + +## Abordagem recomendada + +```mermaid +flowchart LR + URL[URL recorte] --> Chips[buildMunicipalityOmniboxChips] + Query[draft query] --> Sugs[buildMunicipalityOmniboxSuggestions] + Facets[facets servidor] --> Sugs + Chips --> UI[CampaignListOmnibox] + Sugs --> UI + UI -->|select/remove/clear| Apply[apply/remove municipality omnibox] + Apply -->|url state| Nav[useCampaignListFilterNavigation] + Apply -->|scenario| Ctx[MunicipalityEstimateScenarioContext] + Header[Header filters] --> URL +``` + +**Opções consideradas:** + +| | A — Chassis genérico + adapter Municípios | B — Reescrever só MunicipalityFilters sem chassis | C — cmdk CommandDialog global + filtros | +| --- | --- | --- | --- | +| | Tipos/UI em `shared` + `lib`; domínio monta chips/sugestões/ações | Tudo no arquivo do piloto | Palette global misturando home search | + +**Recomendação:** A — porque B128 precisa do mesmo gesto; deep module (UI burra + builders puros); evita twin na adoção. +**Rejeitadas:** B porque a segunda lista copia 200 linhas; C viola anti-goal “command palette global”. + +### Decisões de engenharia + +1. **Fonte de verdade** + Opções: URL-only | URL + React espelho | Context unificado. + **Rec:** URL para filtros/sort/`q`; scenario continua no context existente (não URL). + **Rejeitadas:** Context unificado (duplica B18); scenario na URL (quebra bookmarks). + +2. **Onde vive o matching de sugestões** + Opções: só no client UI | pure em `lib`/`utilities` do domínio. + **Rec:** pure `municipalityOmnibox.ts` (utilities, client-safe) + helper genérico mínimo em `lib/campaignListOmnibox.ts` (tipos + match includes via `normalizeSearchPhrase`). + **Rejeitadas:** lógica só no JSX (intestável); DSL cross-domínio agora (1 call site piloto — B128 extrai se precisar). + +3. **Confirmação de texto livre** + Opções: debounce automático (status quo) | Enter/sugestão “Busca: …” | ambos. + **Rec:** Enter ou escolha da sugestão “Busca: {texto}” confirma chip; **sem** debounce que muda `q` a cada tecla — a digitação alimenta sugestões; o chip é o recorte. + **Rejeitadas:** só debounce (contradiz “confirmado”); manter debounce + chip (dois estados de texto). + +4. **Chips de apresentação (cenário / sort)** + Opções: sempre visíveis | só quando ≠ default. + **Rec:** só quando ≠ default (cenário ≠ central; sort ≠ deficit+desc). Digitar “cenário”/“ordenar” sugere opções. Remover chip → default. + **Rejeitadas:** sempre visíveis (barra barulhenta no default). + +5. **Empty focus** + Opções: listar tudo | nada | atalhos de dimensão. + **Rec:** com query vazia, sugestões = atalhos de dimensão/apresentação (Prioritária, Cenário…, Ordenar…) sem dump de 435 municípios; com texto, filtra valores + “Busca: …”. + **Rejeitadas:** dump completo (inútil em mobile). + +### Componentes / mudanças + +- **`lib/campaignListOmnibox.ts`**: tipos `CampaignListOmniboxChip` / `Suggestion` + `omniboxQueryMatches` (includes normalizado). +- **`shared/CampaignListOmnibox.tsx`**: input com chips removíveis, popover/cmdk de sugestões agrupadas, Limpar, pending; reusa `useCampaignListTransition` via props `isPending`. +- **`utilities/municipality/municipalityOmnibox.ts`**: `buildMunicipalityOmniboxChips`, `buildMunicipalityOmniboxSuggestions`, `applyMunicipalityOmniboxSuggestion`, `removeMunicipalityOmniboxChip` (resultado discriminado `url` | `scenario` | `noop`). +- **`MunicipalityFilters.tsx`**: substitui pilha (search + cenário solto + mobile selects + sort select) pela omnibox + `SaveMunicipalityFilterControl`; recebe também `slugFilterOptions` (facets). +- **`MunicipalityList.tsx`**: remove `

` “Ordenado por …” (caption `sr-only` permanece). +- **`municipios/page.tsx`**: passa facets de slug para filters. +- **Migration:** sem migration. +- **Access / Consent:** inalterado. +- **UI:** Impeccable C — shape→craft na barra existente; tokens `data-theme='campaign'`; touch `min-h-11`. + +### Dados → forma + +Não aplica (só UI do recorte). + +## Fases verificáveis + +1. **Tracer** — pure omnibox builders + unit tests (chips/sugestões/apply/remove/cenário/sort). +2. **UI** — `CampaignListOmnibox` + `MunicipalityFilters` piloto; remover pilha e sort label solto. +3. **Gates** — `pnpm gate:fast`; entrega `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Adoção B128 nas outras listas. +- Mover B18 para dentro da omnibox. +- Debounce de busca “como estava” + chip simultâneo. +- Virtualizar 435 sugestões (cap por grupo basta no piloto). +- Redesign do header filter / mapa. + +## Riscos e mitigação + +- **Dois estados de `q` (draft vs chip):** draft só no input; committed só via apply “Busca”; sync URL→draft só quando não há digitação pendente (mesmo padrão do nav hook, sem debounce de commit). +- **Sidebar B18:** não importar serializador no layout; Save control continua no domínio. +- **Header sync:** ambas as UIs só editam URL → chips refletem no RSC refresh; optimistic opcional nos chips via state prop. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio: unit em builders omnibox + regressão municipalityList filters/URL + +**Decision-quality self-score:** 5/5 (rejeitadas nas caras; appetite respeitado; rabbit holes nomeados; reusa URL/nav/header/B18; outcome intacto). diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index dd6d067f..5b0c0e99 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -99,6 +99,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie showStaffFilters={isStaffView} regionFilterOptions={columnFilterOptions.region} advisorFilterOptions={columnFilterOptions.advisor} + slugFilterValues={columnFilterOptions.name} /> ) diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx index 4e9fd10a..ae3ac932 100644 --- a/src/components/campaign/municipality/MunicipalityFilters.tsx +++ b/src/components/campaign/municipality/MunicipalityFilters.tsx @@ -1,54 +1,25 @@ 'use client' -import { useMunicipalityEstimateScenario } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' +import { useMemo, useState } from 'react' + +import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl' -import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField' -import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' +import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox' import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' -import { - VOTE_ESTIMATE_SCENARIO_FILTERS_HINT, - VoteEstimateScenarioField, -} from '@/components/campaign/votePledge/VoteEstimateScenarioField' -import { Button } from '@/components/ui/button' -import { Field, FieldLabel } from '@/components/ui/field' -import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate' import { buildMunicipalityFilterHref, - clearMunicipalityListFilters, - formatMunicipalityActiveFiltersSummary, - getMunicipalityMultiFilterValues, - municipalityFilterDefinitions, - toggleMunicipalityExclusiveFilterValue, - toggleMunicipalityMultiFilterValue, + municipalityFilterOptionsForSlugs, type MunicipalityFilterOption, - type MunicipalityMultiFilterParam, } from '@/utilities/municipality/municipalityListFilters' import { - municipalityListSortOptions, - parseMunicipalitySortValue, - resolveMunicipalityListSort, - serializeMunicipalitySortValue, - type MunicipalityListState, -} from '@/utilities/municipality/municipalityListUrl' - -const MunicipalityFilterEstimateScenario = ({ - className, - id, -}: { - className?: string - id: string -}) => { - const { scenario, setScenario } = useMunicipalityEstimateScenario() - return ( - - ) -} + applyMunicipalityOmniboxSuggestion, + buildMunicipalityOmniboxChips, + buildMunicipalityOmniboxSuggestions, + clearMunicipalityOmnibox, + removeMunicipalityOmniboxChip, +} from '@/utilities/municipality/municipalityOmnibox' +import type { MunicipalityListState } from '@/utilities/municipality/municipalityListUrl' type MunicipalityFiltersProps = { state: MunicipalityListState @@ -57,6 +28,8 @@ type MunicipalityFiltersProps = { regionFilterOptions: MunicipalityFilterOption[] /** Advisor options already narrowed by the other active filters. */ advisorFilterOptions: MunicipalityFilterOption[] + /** Facet slugs (labeled from the catalog on the client). */ + slugFilterValues?: readonly string[] } export const MunicipalityFilters = ({ @@ -64,188 +37,87 @@ export const MunicipalityFilters = ({ showStaffFilters, regionFilterOptions, advisorFilterOptions, + slugFilterValues = [], }: MunicipalityFiltersProps) => { - const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } = - useCampaignListFilterNavigation({ state, toHref: buildMunicipalityFilterHref }) - const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state) - const activeFiltersSummary = formatMunicipalityActiveFiltersSummary({ - ...state, - q: draftQ, + const { navigateWithSearch, isPending } = useCampaignListFilterNavigation({ + state, + toHref: buildMunicipalityFilterHref, }) - const hasActiveFilters = Boolean(activeFiltersSummary) + const scenarioContext = useMunicipalityEstimateScenarioOptional() + const scenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO + const setScenario = scenarioContext?.setScenario + const [query, setQuery] = useState('') - const mobileFilterDefinitions = municipalityFilterDefinitions.filter((definition) => { - if (definition.staffOnly && !showStaffFilters) return false - if (definition.param === 'name') return false - return true + const slugFilterOptions = useMemo( + () => municipalityFilterOptionsForSlugs(slugFilterValues), + [slugFilterValues], + ) + + const advisorLabelsById = useMemo(() => { + const map = new Map() + for (const option of advisorFilterOptions) { + const id = Number(option.value) + if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label) + } + return map + }, [advisorFilterOptions]) + + const chips = buildMunicipalityOmniboxChips({ + state, + scenario, + showStaffFilters, + advisorLabelsById, }) + const suggestions = buildMunicipalityOmniboxSuggestions({ + query, + showStaffFilters, + regionFilterOptions, + advisorFilterOptions, + slugFilterOptions, + }) + + const runAction = (action: ReturnType) => { + if (action.kind === 'scenario') { + setScenario?.(action.scenario) + return + } + if (action.kind === 'clear') { + setScenario?.(action.scenario) + setQuery('') + navigateWithSearch(action.state) + return + } + navigateWithSearch(action.state) + } + return (

{ event.preventDefault() - navigateWithSearch(state) }} > -
- onSearchChange(event.target.value)} - /> - {activeFiltersSummary ? ( -

- {activeFiltersSummary} -

- ) : null} - {showStaffFilters ? ( - - ) : null} - - {hasActiveFilters ? ( - - ) : null} -
- -
- {activeFiltersSummary ? ( -

{activeFiltersSummary}

- ) : null} - {showStaffFilters ? ( - - Prioridade - { - navigateWithSearch({ - ...state, - priority: event.target.value === 'alta' ? 'alta' : undefined, - }) - }} - className="min-h-11 w-full" - > - Todas - Prioritária - - - ) : null} - {mobileFilterDefinitions.map((definition) => { - if (definition.selection === 'multi') { - let options = definition.options ?? [] - if (definition.param === 'region') options = regionFilterOptions - if (definition.param === 'advisor') options = advisorFilterOptions - - if (options.length === 0) return null - - let selected: string[] = [] - if (definition.param !== 'region' && definition.param !== 'advisor') { - selected = getMunicipalityMultiFilterValues( - state, - definition.param as MunicipalityMultiFilterParam, - ) - } else if (definition.param === 'region') { - selected = state.regions ?? [] - } else if (definition.param === 'advisor') { - selected = (state.advisors ?? []).map(String) - } - - return ( - - navigateWithSearch( - toggleMunicipalityMultiFilterValue( - state, - definition.param as MunicipalityMultiFilterParam, - value, - ), - ) - } - /> - ) - } - - const value = definition.param === 'coverage' ? (state.coverage ?? '') : '' - return ( - - - {definition.label} - - { - const selected = event.target.value - if (definition.selection === 'toggle') { - navigateWithSearch( - selected - ? toggleMunicipalityExclusiveFilterValue(state, 'coverage', selected) - : { ...state, coverage: undefined }, - ) - } - }} - className="min-h-11 w-full" - > - {definition.allLabel ?? 'Todas'} - {(definition.options ?? []).map((option) => ( - - {option.label} - - ))} - - - ) - })} - {showStaffFilters ? ( - - ) : null} - - Ordenar - { - const parsed = parseMunicipalitySortValue(event.target.value) - if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir }) - }} - className="min-h-11 w-full" - > - {municipalityListSortOptions.map(({ key, dir, label }) => ( - - {label} - - ))} - - -
+ { + runAction(applyMunicipalityOmniboxSuggestion({ state, suggestionId })) + }} + onRemoveChip={(chipId) => { + runAction(removeMunicipalityOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearMunicipalityOmnibox(state)) + }} + trailing={} + /> ) } diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index f120c9d3..0f761667 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -427,10 +427,6 @@ export const MunicipalityList = (props: MunicipalityListProps) => { return ( <> -

- {sortSummary} -

- void + onSelectSuggestion: (suggestionId: string) => void + onRemoveChip: (chipId: string) => void + onClearAll?: () => void + isPending?: boolean + /** Domain controls beside the bar (e.g. save bookmark). */ + trailing?: ReactNode +} + +/** + * Shared list omnibox chassis (B127): chips inside the field, caret to the + * right, grouped suggestions from the domain adapter. Navigation / URL policy + * stay in the caller. + */ +export const CampaignListOmnibox = ({ + id: idProp, + label, + placeholder, + chips, + suggestions, + query, + onQueryChange, + onSelectSuggestion, + onRemoveChip, + onClearAll, + isPending = false, + trailing, +}: CampaignListOmniboxProps) => { + const reactId = useId() + const id = idProp ?? `campaign-list-omnibox-${reactId}` + const listboxId = `${id}-suggestions` + const inputRef = useRef(null) + const [open, setOpen] = useState(false) + const [activeChipId, setActiveChipId] = useState(null) + + const hasChips = chips.length > 0 + const showSuggestions = open && suggestions.length > 0 + + useEffect(() => { + if (!open) setActiveChipId(null) + }, [open]) + + const selectSuggestion = (suggestionId: string) => { + onSelectSuggestion(suggestionId) + onQueryChange('') + setOpen(false) + inputRef.current?.focus() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (open) { + event.preventDefault() + setOpen(false) + } + return + } + + if (event.key === 'Backspace' && query.length === 0 && chips.length > 0) { + event.preventDefault() + if (activeChipId) { + onRemoveChip(activeChipId) + setActiveChipId(null) + return + } + const last = chips[chips.length - 1] + if (last) setActiveChipId(last.id) + return + } + + if (event.key === 'Enter') { + const trimmed = query.trim() + if (!trimmed) return + event.preventDefault() + // Prefer the explicit Busca suggestion when present; else first match. + const searchSuggestion = suggestions.find((entry) => entry.id === `q:${trimmed}`) + const first = searchSuggestion ?? suggestions[0] + if (first) selectSuggestion(first.id) + } + } + + const groups = (() => { + const map = new Map() + for (const suggestion of suggestions) { + const bucket = map.get(suggestion.group) ?? [] + bucket.push(suggestion) + map.set(suggestion.group, bucket) + } + return [...map.entries()] + })() + + return ( +
+
+ + { + if (!next) setOpen(false) + }} + > + +
{ + // Keep focus on the input when clicking chips/padding. + if (event.target === event.currentTarget) inputRef.current?.focus() + }} + > + {chips.map((chip) => ( + + {chip.label} + + + ))} + { + setActiveChipId(null) + onQueryChange(event.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + className="min-h-8 min-w-[8rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + > + + + {groups.map(([group, items]) => ( + + {items.map((suggestion) => ( + selectSuggestion(suggestion.id)} + className="min-h-11" + > + {suggestion.label} + + ))} + + ))} + + + +
+
+ +
+ {trailing} + {onClearAll && hasChips ? ( + + ) : null} +
+
+ ) +} diff --git a/src/lib/campaignListOmnibox.ts b/src/lib/campaignListOmnibox.ts new file mode 100644 index 00000000..5bee0ff0 --- /dev/null +++ b/src/lib/campaignListOmnibox.ts @@ -0,0 +1,34 @@ +/** + * Shared omnibox types + query matching for campaign list filter bars (B127). + * Domain adapters build chips/suggestions; the shared UI only renders them. + */ +import { normalizeSearchPhrase } from '@/lib/wordStartFilter' + +export type CampaignListOmniboxChip = { + id: string + /** Full chip label, e.g. "Busca: Feira" or "Prioritária". */ + label: string +} + +export type CampaignListOmniboxSuggestion = { + id: string + group: string + label: string + /** Extra haystack tokens (dimension keywords) beyond `label`. */ + keywords?: readonly string[] +} + +/** Substring match after accent-fold / pt-BR lowercasing. Empty needle matches all. */ +export const omniboxQueryMatches = ( + haystack: string, + needle: string, + keywords: readonly string[] = [], +): boolean => { + const normalizedNeedle = normalizeSearchPhrase(needle) + if (!normalizedNeedle) return true + if (normalizeSearchPhrase(haystack).includes(normalizedNeedle)) return true + return keywords.some((keyword) => normalizeSearchPhrase(keyword).includes(normalizedNeedle)) +} + +export const omniboxGroupMatches = (group: string, needle: string): boolean => + omniboxQueryMatches(group, needle) diff --git a/src/utilities/municipality/municipalityOmnibox.ts b/src/utilities/municipality/municipalityOmnibox.ts new file mode 100644 index 00000000..1b3097ff --- /dev/null +++ b/src/utilities/municipality/municipalityOmnibox.ts @@ -0,0 +1,431 @@ +/** + * Municipality list omnibox adapter (B127): chips, suggestions and apply/remove + * over the frozen URL contract + presentation scenario. Pure / client-safe. + */ +import { + omniboxGroupMatches, + omniboxQueryMatches, + type CampaignListOmniboxChip, + type CampaignListOmniboxSuggestion, +} from '@/lib/campaignListOmnibox' +import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + VOTE_ESTIMATE_SCENARIOS, + voteEstimateScenarioLabels, + type VoteEstimateScenario, +} from '@/lib/voteEstimate' +import { + clearMunicipalityListFilters, + municipalityFilterDefinitions, + toggleMunicipalityExclusiveFilterValue, + toggleMunicipalityMultiFilterValue, + toggleMunicipalityPriorityFilter, + type MunicipalityFilterOption, + type MunicipalityMultiFilterParam, +} from '@/utilities/municipality/municipalityListFilters' +import { + municipalityListCoverageLabels, + municipalityPriorityLabels, +} from '@/utilities/municipality/municipalityLabels' +import { + defaultMunicipalityListSortDir, + municipalityListSortOptions, + resolveMunicipalityListSort, + type MunicipalityListState, +} from '@/utilities/municipality/municipalityListUrl' + +const DEFAULT_MUNICIPALITY_SORT_KEY = 'deficit' as const + +const SUGGESTION_CAP_PER_GROUP = 8 + +export type MunicipalityOmniboxAction = + | { kind: 'url'; state: MunicipalityListState } + | { kind: 'scenario'; scenario: VoteEstimateScenario } + | { kind: 'clear'; state: MunicipalityListState; scenario: VoteEstimateScenario } + +const isDefaultMunicipalitySort = (state: MunicipalityListState): boolean => { + const { sort, dir } = resolveMunicipalityListSort(state) + return sort === DEFAULT_MUNICIPALITY_SORT_KEY && dir === defaultMunicipalityListSortDir(sort) +} + +const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}` + +export const buildMunicipalityOmniboxChips = ({ + state, + scenario, + showStaffFilters, + advisorLabelsById, +}: { + state: MunicipalityListState + scenario: VoteEstimateScenario + showStaffFilters: boolean + advisorLabelsById: ReadonlyMap +}): CampaignListOmniboxChip[] => { + const chips: CampaignListOmniboxChip[] = [] + + if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) }) + + if (showStaffFilters && state.priority === 'alta') { + chips.push({ id: 'priority', label: municipalityPriorityLabels.alta }) + } + + for (const slug of state.slugs ?? []) { + chips.push({ + id: `slug:${slug}`, + label: chipLabel('Município', getMunicipalityCatalogEntry(slug)?.name ?? slug), + }) + } + + for (const region of state.regions ?? []) { + chips.push({ id: `region:${region}`, label: chipLabel('Território', region) }) + } + + if (showStaffFilters) { + for (const advisorId of state.advisors ?? []) { + chips.push({ + id: `advisor:${advisorId}`, + label: chipLabel('Assessor', advisorLabelsById.get(advisorId) ?? `Assessor #${advisorId}`), + }) + } + + if (state.coverage) { + chips.push({ + id: `coverage:${state.coverage}`, + label: chipLabel('Assessoria', municipalityListCoverageLabels[state.coverage]), + }) + } + + for (const trend of state.trends ?? []) { + const definition = municipalityFilterDefinitions.find((entry) => entry.param === 'trend') + const option = definition?.options?.find((entry) => entry.value === trend) + chips.push({ + id: `trend:${trend}`, + label: chipLabel('Tendência', option?.label ?? trend), + }) + } + + for (const territorialClass of state.classes ?? []) { + const definition = municipalityFilterDefinitions.find((entry) => entry.param === 'class') + const option = definition?.options?.find((entry) => entry.value === territorialClass) + chips.push({ + id: `class:${territorialClass}`, + label: chipLabel('Classe', option?.label ?? territorialClass), + }) + } + + for (const level of state.levels ?? []) { + const definition = municipalityFilterDefinitions.find((entry) => entry.param === 'level') + const option = definition?.options?.find((entry) => entry.value === level) + chips.push({ + id: `level:${level}`, + label: chipLabel('Nível', option?.label ?? level), + }) + } + + if (scenario !== DEFAULT_VOTE_ESTIMATE_SCENARIO) { + chips.push({ + id: 'scenario', + label: chipLabel('Cenário', voteEstimateScenarioLabels[scenario]), + }) + } + } + + if (!isDefaultMunicipalitySort(state)) { + const { sort, dir } = resolveMunicipalityListSort(state) + const option = municipalityListSortOptions.find( + (entry) => entry.key === sort && entry.dir === dir, + ) + chips.push({ + id: 'sort', + label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`), + }) + } + + return chips +} + +type SuggestionSeed = CampaignListOmniboxSuggestion & { + /** When true, show even with an empty query (dimension shortcuts). */ + emptyQueryVisible?: boolean +} + +const pushMatching = ( + out: CampaignListOmniboxSuggestion[], + seeds: readonly SuggestionSeed[], + query: string, +): void => { + const needle = query.trim() + const matched: CampaignListOmniboxSuggestion[] = [] + for (const seed of seeds) { + const { emptyQueryVisible, ...suggestion } = seed + if (!needle) { + if (emptyQueryVisible) matched.push(suggestion) + continue + } + const groupHit = omniboxGroupMatches(suggestion.group, needle) + const labelHit = omniboxQueryMatches(suggestion.label, needle, suggestion.keywords ?? []) + if (groupHit || labelHit) matched.push(suggestion) + } + + const byGroup = new Map() + for (const suggestion of matched) { + const bucket = byGroup.get(suggestion.group) ?? [] + if (bucket.length >= SUGGESTION_CAP_PER_GROUP) continue + bucket.push(suggestion) + byGroup.set(suggestion.group, bucket) + } + for (const bucket of byGroup.values()) out.push(...bucket) +} + +export const buildMunicipalityOmniboxSuggestions = ({ + query, + showStaffFilters, + regionFilterOptions, + advisorFilterOptions, + slugFilterOptions, +}: { + query: string + showStaffFilters: boolean + regionFilterOptions: readonly MunicipalityFilterOption[] + advisorFilterOptions: readonly MunicipalityFilterOption[] + slugFilterOptions: readonly MunicipalityFilterOption[] +}): CampaignListOmniboxSuggestion[] => { + const suggestions: CampaignListOmniboxSuggestion[] = [] + const trimmed = query.trim() + + if (trimmed) { + suggestions.push({ + id: `q:${trimmed}`, + group: 'Busca', + label: chipLabel('Busca', trimmed), + keywords: ['busca', 'pesquisar', 'texto'], + }) + } + + const seeds: SuggestionSeed[] = [] + + if (showStaffFilters) { + seeds.push({ + id: 'priority:alta', + group: 'Prioritária', + label: municipalityPriorityLabels.alta, + keywords: ['prioritaria', 'prioridade', 'alta'], + emptyQueryVisible: true, + }) + } + + for (const option of slugFilterOptions) { + seeds.push({ + id: `slug:${option.value}`, + group: 'Município', + label: option.label, + keywords: ['municipio', option.value], + }) + } + + for (const option of regionFilterOptions) { + seeds.push({ + id: `region:${option.value}`, + group: 'Território', + label: option.label, + keywords: ['territorio', 'regiao'], + emptyQueryVisible: false, + }) + } + + if (showStaffFilters) { + for (const option of advisorFilterOptions) { + seeds.push({ + id: `advisor:${option.value}`, + group: 'Assessor', + label: option.label, + keywords: ['assessor', 'assessoria'], + }) + } + + for (const [value, label] of Object.entries(municipalityListCoverageLabels)) { + seeds.push({ + id: `coverage:${value}`, + group: 'Assessoria', + label, + keywords: ['cobertura', 'assessor'], + emptyQueryVisible: true, + }) + } + + for (const definition of municipalityFilterDefinitions) { + if (definition.param !== 'trend' && definition.param !== 'class' && definition.param !== 'level') { + continue + } + for (const option of definition.options ?? []) { + seeds.push({ + id: `${definition.param}:${option.value}`, + group: definition.label, + label: option.label, + keywords: [definition.label.toLowerCase()], + }) + } + } + + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + seeds.push({ + id: `scenario:${scenario}`, + group: 'Cenário', + label: voteEstimateScenarioLabels[scenario], + keywords: ['cenario', 'estimativa', 'cenário'], + emptyQueryVisible: true, + }) + } + } + + for (const option of municipalityListSortOptions) { + seeds.push({ + id: `sort:${option.key}|${option.dir}`, + group: 'Ordenação', + label: option.label, + keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'], + }) + } + + // Empty focus: surface dimension shortcuts only (no 435-município dump). + if (!trimmed) { + for (const seed of seeds) { + if (!seed.emptyQueryVisible) continue + const { emptyQueryVisible: _visible, ...suggestion } = seed + suggestions.push(suggestion) + } + // Always offer a few sort entry points when idle? Product: type "ordenar". + // Keep empty focus lean — only exclusive/presentation shortcuts above. + return suggestions + } + + pushMatching(suggestions, seeds, trimmed) + return suggestions +} + +const withPageReset = (state: MunicipalityListState): MunicipalityListState => ({ + ...state, + page: 1, +}) + +const applyMultiToggle = ( + state: MunicipalityListState, + param: MunicipalityMultiFilterParam, + value: string, +): MunicipalityListState => toggleMunicipalityMultiFilterValue(state, param, value) + +export const applyMunicipalityOmniboxSuggestion = ({ + state, + suggestionId, +}: { + state: MunicipalityListState + suggestionId: string +}): MunicipalityOmniboxAction => { + if (suggestionId.startsWith('q:')) { + const q = suggestionId.slice(2) + return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) } + } + + if (suggestionId === 'priority:alta') { + return { kind: 'url', state: toggleMunicipalityPriorityFilter(state) } + } + + if (suggestionId.startsWith('slug:')) { + return { kind: 'url', state: applyMultiToggle(state, 'slug', suggestionId.slice(5)) } + } + if (suggestionId.startsWith('region:')) { + return { kind: 'url', state: applyMultiToggle(state, 'region', suggestionId.slice(7)) } + } + if (suggestionId.startsWith('advisor:')) { + return { kind: 'url', state: applyMultiToggle(state, 'advisor', suggestionId.slice(8)) } + } + if (suggestionId.startsWith('trend:')) { + return { kind: 'url', state: applyMultiToggle(state, 'trend', suggestionId.slice(6)) } + } + if (suggestionId.startsWith('class:')) { + return { kind: 'url', state: applyMultiToggle(state, 'class', suggestionId.slice(6)) } + } + if (suggestionId.startsWith('level:')) { + return { kind: 'url', state: applyMultiToggle(state, 'level', suggestionId.slice(6)) } + } + + if (suggestionId.startsWith('coverage:')) { + const value = suggestionId.slice(9) + if (value !== 'com_assessor' && value !== 'sem_assessor') return { kind: 'url', state } + return { kind: 'url', state: toggleMunicipalityExclusiveFilterValue(state, 'coverage', value) } + } + + if (suggestionId.startsWith('scenario:')) { + const scenario = suggestionId.slice(9) + if (!(VOTE_ESTIMATE_SCENARIOS as readonly string[]).includes(scenario)) { + return { kind: 'url', state } + } + return { kind: 'scenario', scenario: scenario as VoteEstimateScenario } + } + + if (suggestionId.startsWith('sort:')) { + const raw = suggestionId.slice(5) + const [key, dir] = raw.split('|') + const option = municipalityListSortOptions.find( + (entry) => entry.key === key && entry.dir === dir, + ) + if (!option) return { kind: 'url', state } + const next = withPageReset({ ...state, sort: option.key, dir: option.dir }) + // Canonical default omits sort/dir from the URL — clear when choosing default. + if (isDefaultMunicipalitySort(next)) { + return { kind: 'url', state: withPageReset({ ...state, sort: undefined, dir: undefined }) } + } + return { kind: 'url', state: next } + } + + return { kind: 'url', state } +} + +export const removeMunicipalityOmniboxChip = ({ + state, + chipId, +}: { + state: MunicipalityListState + chipId: string +}): MunicipalityOmniboxAction => { + if (chipId === 'q') { + return { kind: 'url', state: withPageReset({ ...state, q: undefined }) } + } + if (chipId === 'priority') { + return { kind: 'url', state: withPageReset({ ...state, priority: undefined }) } + } + if (chipId === 'scenario') { + return { kind: 'scenario', scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO } + } + if (chipId === 'sort') { + return { kind: 'url', state: withPageReset({ ...state, sort: undefined, dir: undefined }) } + } + + const multiPrefixes: Array<{ prefix: string; param: MunicipalityMultiFilterParam }> = [ + { prefix: 'slug:', param: 'slug' }, + { prefix: 'region:', param: 'region' }, + { prefix: 'advisor:', param: 'advisor' }, + { prefix: 'trend:', param: 'trend' }, + { prefix: 'class:', param: 'class' }, + { prefix: 'level:', param: 'level' }, + ] + for (const { prefix, param } of multiPrefixes) { + if (!chipId.startsWith(prefix)) continue + const value = chipId.slice(prefix.length) + // Toggle removes when present. + return { kind: 'url', state: applyMultiToggle(state, param, value) } + } + + if (chipId.startsWith('coverage:')) { + return { kind: 'url', state: withPageReset({ ...state, coverage: undefined }) } + } + + return { kind: 'url', state } +} + +export const clearMunicipalityOmnibox = (state: MunicipalityListState): MunicipalityOmniboxAction => ({ + kind: 'clear', + state: clearMunicipalityListFilters(state), + scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO, +}) diff --git a/tests/unit/campaignListFilterNavigation.unit.spec.ts b/tests/unit/campaignListFilterNavigation.unit.spec.ts index 944bc3ef..42678634 100644 --- a/tests/unit/campaignListFilterNavigation.unit.spec.ts +++ b/tests/unit/campaignListFilterNavigation.unit.spec.ts @@ -11,18 +11,15 @@ vi.mock('next/navigation', async (importActual) => ({ useRouter: () => ({ replace: routerState.replace }), })) -import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' -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. + * List filter shells that still use the debounced-search scaffold + * (`useCampaignListFilterNavigation`). Municípios left this pattern in B127 + * (omnibox); the hook contract is pinned by dobradinhas + territórios. */ type FilterCase = { name: string @@ -79,35 +76,9 @@ const filterCases: FilterCase[] = [ regionOptions: [], }), }, - { - name: 'municípios', - searchLabel: 'Buscar município', - hrefWithQuery: '/campanha/municipios?q=silva', - bareHref: '/campanha/municipios', - sortValue: 'name|asc', - element: (currentQuery) => - createElement( - MunicipalityEstimateScenarioProvider, - null, - createElement(MunicipalityFilters, { - state: { page: 1, ...(currentQuery ? { q: currentQuery } : {}) }, - showStaffFilters: true, - regionFilterOptions: [], - advisorFilterOptions: [], - }), - ), - elementWithSort: () => - createElement( - MunicipalityEstimateScenarioProvider, - null, - createElement(MunicipalityFilters, { - state: { page: 1, sort: 'name', dir: 'asc' }, - showStaffFilters: true, - regionFilterOptions: [], - advisorFilterOptions: [], - }), - ), - }, + // Municípios (B127) left the debounced-search shell for the omnibox; the + // shared hook contract stays pinned by dobradinhas + territórios. Omnibox + // apply/remove lives in municipalityOmnibox.unit.spec.ts. ] /** diff --git a/tests/unit/municipalityOmnibox.unit.spec.ts b/tests/unit/municipalityOmnibox.unit.spec.ts new file mode 100644 index 00000000..80f959c4 --- /dev/null +++ b/tests/unit/municipalityOmnibox.unit.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate' +import { + applyMunicipalityOmniboxSuggestion, + buildMunicipalityOmniboxChips, + buildMunicipalityOmniboxSuggestions, + clearMunicipalityOmnibox, + removeMunicipalityOmniboxChip, +} from '@/utilities/municipality/municipalityOmnibox' +import { parseMunicipalityListParams } from '@/utilities/municipality/municipalityListUrl' + +describe('municipality omnibox (B127)', () => { + const base = parseMunicipalityListParams({}) + + it('builds chips from URL filters and non-default presentation', () => { + const state = parseMunicipalityListParams({ + q: 'feira', + priority: 'alta', + region: 'Recôncavo', + sort: 'name', + dir: 'asc', + }) + const chips = buildMunicipalityOmniboxChips({ + state, + scenario: 'optimistic', + showStaffFilters: true, + advisorLabelsById: new Map(), + }) + expect(chips.map((chip) => chip.id)).toEqual([ + 'q', + 'priority', + 'region:Recôncavo', + 'scenario', + 'sort', + ]) + expect(chips.find((chip) => chip.id === 'q')?.label).toBe('Busca: feira') + }) + + it('hides default scenario and default sort chips', () => { + const chips = buildMunicipalityOmniboxChips({ + state: base, + scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO, + showStaffFilters: true, + advisorLabelsById: new Map(), + }) + expect(chips).toEqual([]) + }) + + it('suggests Busca and matching dimensions from typed text', () => { + const suggestions = buildMunicipalityOmniboxSuggestions({ + query: 'pri', + showStaffFilters: true, + regionFilterOptions: [], + advisorFilterOptions: [], + slugFilterOptions: [{ value: 'salvador', label: 'Salvador' }], + }) + expect(suggestions.some((entry) => entry.id === 'q:pri')).toBe(true) + expect(suggestions.some((entry) => entry.id === 'priority:alta')).toBe(true) + }) + + it('suggests sort options when typing ordenar', () => { + const suggestions = buildMunicipalityOmniboxSuggestions({ + query: 'ordenar', + showStaffFilters: true, + regionFilterOptions: [], + advisorFilterOptions: [], + slugFilterOptions: [], + }) + expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true) + }) + + it('applies text search and toggles inclusive filters', () => { + const search = applyMunicipalityOmniboxSuggestion({ + state: base, + suggestionId: 'q:Salvador', + }) + expect(search).toEqual({ + kind: 'url', + state: expect.objectContaining({ q: 'Salvador', page: 1 }), + }) + + const withRegion = applyMunicipalityOmniboxSuggestion({ + state: base, + suggestionId: 'region:Recôncavo', + }) + expect(withRegion.kind).toBe('url') + if (withRegion.kind === 'url') { + expect(withRegion.state.regions).toEqual(['Recôncavo']) + } + }) + + it('applies scenario as presentation action and clears to central', () => { + const applied = applyMunicipalityOmniboxSuggestion({ + state: base, + suggestionId: 'scenario:optimistic', + }) + expect(applied).toEqual({ kind: 'scenario', scenario: 'optimistic' }) + + const removed = removeMunicipalityOmniboxChip({ + state: base, + chipId: 'scenario', + }) + expect(removed).toEqual({ kind: 'scenario', scenario: 'central' }) + }) + + it('removes a single multi-value chip without clearing siblings', () => { + const state = parseMunicipalityListParams({ + region: ['Recôncavo', 'Portal do Sertão'], + }) + const next = removeMunicipalityOmniboxChip({ state, chipId: 'region:Recôncavo' }) + expect(next.kind).toBe('url') + if (next.kind === 'url') { + expect(next.state.regions).toEqual(['Portal do Sertão']) + } + }) + + it('clear drops filters and resets scenario to central, keeping sort', () => { + const state = parseMunicipalityListParams({ + q: 'x', + priority: 'alta', + sort: 'name', + dir: 'asc', + }) + expect(clearMunicipalityOmnibox(state)).toEqual({ + kind: 'clear', + state: { page: 1, sort: 'name', dir: 'asc' }, + scenario: 'central', + }) + }) +})