mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(campanha): B127 omnibox filter bar chassis + Municípios pilot
Replace the stacked município filter controls with a shared omnibox (chips + suggestions) backed by the frozen list URL contract; scenario and non-default sort appear as presentation chips. Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
544292f8f4
commit
21e20a6719
9 changed files with 1033 additions and 251 deletions
108
docs/plans/barra-filtros-omnibox-listas-impl.md
Normal file
108
docs/plans/barra-filtros-omnibox-listas-impl.md
Normal file
|
|
@ -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 `<p>` “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).
|
||||
|
|
@ -99,6 +99,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
showStaffFilters={isStaffView}
|
||||
regionFilterOptions={columnFilterOptions.region}
|
||||
advisorFilterOptions={columnFilterOptions.advisor}
|
||||
slugFilterValues={columnFilterOptions.name}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<VoteEstimateScenarioField
|
||||
id={id}
|
||||
value={scenario}
|
||||
onChange={setScenario}
|
||||
hint={VOTE_ESTIMATE_SCENARIO_FILTERS_HINT}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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<number, string>()
|
||||
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<typeof applyMunicipalityOmniboxSuggestion>) => {
|
||||
if (action.kind === 'scenario') {
|
||||
setScenario?.(action.scenario)
|
||||
return
|
||||
}
|
||||
if (action.kind === 'clear') {
|
||||
setScenario?.(action.scenario)
|
||||
setQuery('')
|
||||
navigateWithSearch(action.state)
|
||||
return
|
||||
}
|
||||
navigateWithSearch(action.state)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending}
|
||||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigateWithSearch(state)
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<CampaignSearchInput
|
||||
id="municipality-search"
|
||||
label="Buscar município"
|
||||
placeholder="Buscar por município ou zona…"
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
{activeFiltersSummary ? (
|
||||
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2 md:whitespace-normal">
|
||||
{activeFiltersSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{showStaffFilters ? (
|
||||
<MunicipalityFilterEstimateScenario
|
||||
id="municipality-filter-estimate-scenario"
|
||||
className="hidden shrink-0 md:block md:self-end"
|
||||
/>
|
||||
) : null}
|
||||
<SaveMunicipalityFilterControl state={state} />
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 shrink-0 md:self-end"
|
||||
onClick={() => clearSearchAndNavigate(clearMunicipalityListFilters(state))}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{activeFiltersSummary ? (
|
||||
<p className="text-sm text-muted-foreground">{activeFiltersSummary}</p>
|
||||
) : null}
|
||||
{showStaffFilters ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-filter-priority">Prioridade</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-filter-priority"
|
||||
value={state.priority ?? ''}
|
||||
onChange={(event) => {
|
||||
navigateWithSearch({
|
||||
...state,
|
||||
priority: event.target.value === 'alta' ? 'alta' : undefined,
|
||||
})
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Todas</NativeSelectOption>
|
||||
<NativeSelectOption value="alta">Prioritária</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
) : 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 (
|
||||
<CampaignMobileMultiFilterField
|
||||
key={definition.param}
|
||||
id={`municipality-filter-${definition.param}`}
|
||||
label={definition.label}
|
||||
emptyLabel={definition.emptyLabel ?? 'Todos'}
|
||||
options={options}
|
||||
selected={selected}
|
||||
onToggle={(value) =>
|
||||
navigateWithSearch(
|
||||
toggleMunicipalityMultiFilterValue(
|
||||
state,
|
||||
definition.param as MunicipalityMultiFilterParam,
|
||||
value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const value = definition.param === 'coverage' ? (state.coverage ?? '') : ''
|
||||
return (
|
||||
<Field key={definition.param}>
|
||||
<FieldLabel htmlFor={`municipality-filter-${definition.param}`}>
|
||||
{definition.label}
|
||||
</FieldLabel>
|
||||
<NativeSelect
|
||||
id={`municipality-filter-${definition.param}`}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const selected = event.target.value
|
||||
if (definition.selection === 'toggle') {
|
||||
navigateWithSearch(
|
||||
selected
|
||||
? toggleMunicipalityExclusiveFilterValue(state, 'coverage', selected)
|
||||
: { ...state, coverage: undefined },
|
||||
)
|
||||
}
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">{definition.allLabel ?? 'Todas'}</NativeSelectOption>
|
||||
{(definition.options ?? []).map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
{showStaffFilters ? (
|
||||
<MunicipalityFilterEstimateScenario
|
||||
id="municipality-filter-estimate-scenario-mobile"
|
||||
className="md:hidden"
|
||||
/>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-sort">Ordenar</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-sort"
|
||||
value={serializeMunicipalitySortValue(activeSort, activeDir)}
|
||||
onChange={(event) => {
|
||||
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 }) => (
|
||||
<NativeSelectOption
|
||||
key={serializeMunicipalitySortValue(key, dir)}
|
||||
value={serializeMunicipalitySortValue(key, dir)}
|
||||
>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
<CampaignListOmnibox
|
||||
id="municipality-omnibox"
|
||||
label="Filtrar municípios"
|
||||
placeholder="Digite para filtrar (município, território, cenário…)"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyMunicipalityOmniboxSuggestion({ state, suggestionId }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeMunicipalityOmniboxChip({ state, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearMunicipalityOmnibox(state))
|
||||
}}
|
||||
trailing={<SaveMunicipalityFilterControl state={state} />}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,10 +427,6 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{sortSummary}
|
||||
</p>
|
||||
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
|
|
|
|||
238
src/components/campaign/shared/CampaignListOmnibox.tsx
Normal file
238
src/components/campaign/shared/CampaignListOmnibox.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
'use client'
|
||||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/Popover'
|
||||
import type {
|
||||
CampaignListOmniboxChip,
|
||||
CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CampaignListOmniboxProps = {
|
||||
id?: string
|
||||
label: string
|
||||
placeholder: string
|
||||
chips: readonly CampaignListOmniboxChip[]
|
||||
suggestions: readonly CampaignListOmniboxSuggestion[]
|
||||
query: string
|
||||
onQueryChange: (query: string) => 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<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeChipId, setActiveChipId] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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<string, CampaignListOmniboxSuggestion[]>()
|
||||
for (const suggestion of suggestions) {
|
||||
const bucket = map.get(suggestion.group) ?? []
|
||||
bucket.push(suggestion)
|
||||
map.set(suggestion.group, bucket)
|
||||
}
|
||||
return [...map.entries()]
|
||||
})()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70 md:flex-row md:items-start"
|
||||
data-pending={isPending}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<Popover
|
||||
open={showSuggestions}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpen(false)
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent px-2 py-1.5 shadow-xs',
|
||||
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
// Keep focus on the input when clicking chips/padding.
|
||||
if (event.target === event.currentTarget) inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.id}
|
||||
data-active={activeChipId === chip.id ? 'true' : undefined}
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground',
|
||||
'data-[active=true]:ring-2 data-[active=true]:ring-ring',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{chip.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
aria-label={`Remover ${chip.label}`}
|
||||
onClick={() => {
|
||||
onRemoveChip(chip.id)
|
||||
setActiveChipId(null)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
role="combobox"
|
||||
aria-expanded={showSuggestions}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={hasChips ? '' : placeholder}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[min(100%,24rem)] p-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false} className="rounded-xl border-0">
|
||||
<CommandList>
|
||||
{groups.map(([group, items]) => (
|
||||
<CommandGroup key={group} heading={group}>
|
||||
{items.map((suggestion) => (
|
||||
<CommandItem
|
||||
key={suggestion.id}
|
||||
value={suggestion.id}
|
||||
onSelect={() => selectSuggestion(suggestion.id)}
|
||||
className="min-h-11"
|
||||
>
|
||||
{suggestion.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 md:pt-7">
|
||||
{trailing}
|
||||
{onClearAll && hasChips ? (
|
||||
<Button type="button" variant="ghost" className="min-h-11 shrink-0" onClick={onClearAll}>
|
||||
Limpar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/lib/campaignListOmnibox.ts
Normal file
34
src/lib/campaignListOmnibox.ts
Normal file
|
|
@ -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)
|
||||
431
src/utilities/municipality/municipalityOmnibox.ts
Normal file
431
src/utilities/municipality/municipalityOmnibox.ts
Normal file
|
|
@ -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<number, string>
|
||||
}): 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<string, CampaignListOmniboxSuggestion[]>()
|
||||
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,
|
||||
})
|
||||
|
|
@ -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.
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
131
tests/unit/municipalityOmnibox.unit.spec.ts
Normal file
131
tests/unit/municipalityOmnibox.unit.spec.ts
Normal file
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue