B128 — Adotar barra omnibox nas demais listas /campanha

Estende o chassis B127 (CampaignListOmnibox + adapters puros por domínio)
para territórios, lideranças, dobradinhas, apoiadores, atividades, demandas,
organizações e assessores. Remove pilhas antigas (search debounced, collapsible,
mobile selects, CampaignFilterChips). URL contracts extraídos para módulos
client-safe onde necessário (demand/organization/advisor). Testes unitários
nos adapters + hook navigate-only.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 20:55:12 +00:00
parent 7a0c4376eb
commit 0903576f9e
No known key found for this signature in database
31 changed files with 2310 additions and 938 deletions

View file

@ -0,0 +1,59 @@
# Impl: B128 — Adotar barra omnibox nas demais listas `/campanha`
Status: aprovado
Atualizado em: 2026-08-02
Issue: #265
Intenção: docs/plans/adotar-barra-filtros-omnibox-listas.md
Appetite restante: todas as listas do mapa B127 (exceto Municípios, já piloto)
## Leitura da intenção
- **Outcome:** Staff usa a mesma barra omnibox (chips + sugestões) em territórios, lideranças, dobradinhas, apoiadores, atividades, demandas, organizações e assessores; abas de atividades ficam fora; semântica/URL congelados.
- **O que NÃO negociar:** contrato URL B18; leader lockdown; não inventar `q` em atividades/demandas; deep-links `activity` em demandas; header filters sincronizados via URL.
- **O que reavaliar:** um adapter gigante cross-domínio — rejeitado; extrair só `filterOmniboxSuggestionSeeds` + `searchOnlyListOmnibox` em `lib/` (2º+ consumidor).
## Abordagem recomendada
```mermaid
flowchart LR
URL[URL recorte] --> Adapter[domain *Omnibox.ts]
Adapter --> UI[CampaignListOmnibox]
UI --> Nav[useCampaignListFilterNavigation navigate]
Header[Header filters] --> URL
```
**Opções:** A) adapter por domínio (espelha B127) · B) DSL única · C) copiar JSX sem adapter
**Recomendação:** A — testável, sem twin; B128 é N listas com mapas distintos.
**Rejeitadas:** B (over-engineering); C (intestável).
### Componentes
- **`lib/campaignListOmnibox.ts`:** `createOmniboxSuggestionSeed`, `filterOmniboxSuggestionSeeds` (cap 8/grupo + chip Busca).
- **`lib/searchOnlyListOmnibox.ts`:** degeneração organizações/assessores.
- **`utilities/<domínio>/*Omnibox.ts`:** território, liderança, dobradinha, apoiador, atividade, demanda.
- **`*Filters.tsx`:** substituem pilha antiga; atividades mantém `ActivityTabSwitch` acima.
- **`DemandFilters` / `OrganizationFilters` / `AdvisorFilters`:** extraídos das pages RSC.
- **Tests:** unit por adapter; `campaignListFilterNavigation` deixa de pinar territórios/dobradinhas (omnibox não debounce).
### Migration / access
Sem migration. Access inalterado.
## Fases
1. Shared helpers + adapters puros + unit tests
2. UI shells (8 listas) — remover `CampaignSearchInput`, collapsible, mobile selects, `CampaignFilterChips` nas rotas tocadas
3. `pnpm gate:fast` → `pnpm push`
## Rabbit holes
- Generalizar saved views (fora de escopo B18)
- Refatorar `municipalityOmnibox` para shared helpers no mesmo PR só se custo baixo
## Aceite de engenharia
- [ ] Aceite de produto da intenção
- [ ] Invariantes URL / lockdown
- [ ] Unit tests nos adapters novos
**Decision-quality:** 5/5

View file

@ -1,15 +1,14 @@
import { ADVISOR_QUICK_CREATE_PARAM } from '@/lib/campaignAdvisorQuickActions'
import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths'
import config from '@payload-config'
import { getPayload } from 'payload'
import { AdvisorFilters } from '@/components/campaign/advisor/AdvisorFilters'
import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import {
@ -50,12 +49,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
return (
<CampaignPageShell>
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar assessor por nome ou e-mail"
placeholder="Buscar por nome ou e-mail…"
initialQuery={state.q ?? ''}
basePath={CAMPAIGN_ADVISORS_HOME}
/>
<AdvisorFilters state={state} />
<CampaignListResults>
<AdvisorsTable

View file

@ -3,7 +3,7 @@ import { InboxIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { getPayload } from 'payload'
import { CampaignFilterChips } from '@/components/campaign/shared/CampaignFilterChips'
import { DemandFilters } from '@/components/campaign/demand/DemandFilters'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
@ -18,7 +18,6 @@ import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
campaignDemandStatuses,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
@ -106,9 +105,6 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
const { rows, totalDocs, totalPages } = await loadDemandListPageData(payload, user, state)
const columnVisibility = await readCampaignColumnVisibility('demandas')
const hrefForStatus = (status?: CampaignDemandStatus) =>
buildDemandListHref({ ...state, status }, 1)
return (
<CampaignPageShell>
<div className="flex justify-end">
@ -121,17 +117,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
</div>
<CampaignListPendingBoundary>
<CampaignFilterChips
ariaLabel="Filtrar por status"
chips={[
{ href: hrefForStatus(undefined), label: 'Todas', active: state.status === undefined },
...campaignDemandStatuses.map((status) => ({
href: hrefForStatus(status),
label: campaignDemandStatusLabels[status],
active: state.status === status,
})),
]}
/>
<DemandFilters state={state} />
<CampaignListResults>
<CampaignTable

View file

@ -352,16 +352,6 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
.filter((option): option is LeadershipFilterOption => option !== null)
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
const municipalityLabelsById: Record<number, string> = {}
for (const option of municipalityFilterOptions) {
municipalityLabelsById[Number(option.value)] = option.label
}
for (const id of state.municipalities ?? []) {
if (!municipalityLabelsById[id]) {
municipalityLabelsById[id] = municipalityById.get(id)?.name ?? `Município #${id}`
}
}
const { sort, dir } = resolveLeadershipListSort(state)
const sortSummary = formatLeadershipListSortSummary(sort, dir)
const columns = leadershipColumns({
@ -394,7 +384,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
</div>
<CampaignListPendingBoundary>
<LeadershipFilters state={state} municipalityLabelsById={municipalityLabelsById} />
<LeadershipFilters state={state} municipalityFilterOptions={municipalityFilterOptions} />
<CampaignListResults>
<p className="text-sm text-muted-foreground" aria-live="polite">

View file

@ -3,13 +3,13 @@ import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { getPayload } from 'payload'
import { OrganizationFilters } from '@/components/campaign/organization/OrganizationFilters'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
@ -87,18 +87,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
</div>
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar organização por nome"
placeholder="Buscar por nome…"
initialQuery={state.q ?? ''}
basePath="/campanha/organizacoes"
// Canonical serialization of the ACTIVE filters minus q/page, so a
// search submit preserves `?kind=` instead of silently dropping it.
filterParams={buildOrganizationListHref({ ...state, q: undefined }, 1).replace(
/^\/campanha\/organizacoes\??/,
'',
)}
/>
<OrganizationFilters state={state} />
<CampaignListResults>
<CampaignTable

View file

@ -1,40 +1,30 @@
'use client'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { CampaignCollapsibleFilterPanel } from '@/components/campaign/shared/CampaignCollapsibleFilterPanel'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useCampaignFilterValues } from '@/components/campaign/shared/useCampaignFilterValues'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity'
import { cn } from '@/lib/utils'
import {
applyActivityOmniboxSuggestion,
buildActivityOmniboxChips,
buildActivityOmniboxSuggestionSeeds,
clearActivityOmnibox,
filterActivityOmniboxSuggestions,
removeActivityOmniboxChip,
type ActivityOmniboxAction,
} from '@/utilities/activityOmnibox'
import {
activityTabLabels,
activityTabs,
buildActivityListHref,
buildActivityListSearchParams,
type ActivityListState,
type ActivityTab,
} from '@/utilities/activityUi'
type FilterValues = {
kind: string
status: string
municipality: string
}
const valuesFromState = (state: ActivityListState): FilterValues => ({
kind: state.kind ?? '',
status: state.status ?? '',
municipality: state.municipality ? String(state.municipality) : '',
})
const municipalityIdFromValue = (value: string): number | undefined => {
const id = Number(value)
return Number.isInteger(id) && id > 0 ? id : undefined
}
const buildTabHref = (state: ActivityListState, tab: ActivityTab): string => {
const params = buildActivityListSearchParams({
page: 1,
@ -74,105 +64,87 @@ export const ActivityFilters = ({
state: ActivityListState
municipalityOptions: RelationOption[]
}) => {
const { values, isPending, replaceValues, updateValues } = useCampaignFilterValues({
committedValues: valuesFromState(state),
toHref: (next) => {
const params = buildActivityListSearchParams({
page: 1,
tab: state.tab,
kind: next.kind as ActivityListState['kind'],
status: next.status as ActivityListState['status'],
municipality: municipalityIdFromValue(next.municipality),
})
const query = params.toString()
return query ? `/campanha/atividades?${query}` : '/campanha/atividades'
},
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildActivityListHref(next, 1),
})
const [query, setQuery] = useState('')
const hasFilters = Object.values(values).some(Boolean)
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of municipalityOptions) {
if (Number.isSafeInteger(option.id) && option.id > 0) map.set(option.id, option.name)
}
return map
}, [municipalityOptions])
const municipalityFilterOptions = useMemo(
() =>
municipalityOptions.map((option) => ({
value: String(option.id),
label: option.name,
})),
[municipalityOptions],
)
const chips = useMemo(
() => buildActivityOmniboxChips({ state, municipalityLabelsById }),
[state, municipalityLabelsById],
)
const suggestionSeeds = useMemo(
() =>
buildActivityOmniboxSuggestionSeeds({
tab: state.tab,
municipalityOptions: municipalityFilterOptions,
}),
[state.tab, municipalityFilterOptions],
)
const suggestions = useMemo(
() => filterActivityOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: ActivityOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
return (
<div className="flex flex-col gap-3">
<ActivityTabSwitch state={state} />
<div
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
<form
role="search"
onSubmit={(event) => {
event.preventDefault()
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<CampaignCollapsibleFilterPanel
panelId="activity-filter-controls"
hasFilters={hasFilters}
onClear={() => replaceValues({ kind: '', status: '', municipality: '' })}
>
<div
className={cn(
'grid gap-4',
state.tab === 'todos' ? 'lg:grid-cols-3' : 'lg:grid-cols-2',
)}
>
<Field>
<FieldLabel htmlFor="activity-kind">Tipo de atividade</FieldLabel>
<NativeSelect
id="activity-kind"
value={values.kind}
onChange={(event) =>
updateValues((current) => ({ ...current, kind: event.target.value }))
}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todos os tipos</NativeSelectOption>
{Object.entries(activityKindLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
{state.tab === 'todos' ? (
<Field>
<FieldLabel htmlFor="activity-status">Status</FieldLabel>
<NativeSelect
id="activity-status"
value={values.status}
onChange={(event) =>
updateValues((current) => ({ ...current, status: event.target.value }))
}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todos os status</NativeSelectOption>
{Object.entries(activityStatusLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
) : null}
<Field>
<FieldLabel htmlFor="activity-municipality">Município</FieldLabel>
<NativeSelect
id="activity-municipality"
value={values.municipality}
onChange={(event) =>
updateValues((current) => ({ ...current, municipality: event.target.value }))
}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todos os municípios</NativeSelectOption>
{municipalityOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
</CampaignCollapsibleFilterPanel>
</div>
<CampaignListOmnibox
id="activity-omnibox"
label="Filtrar atividades"
placeholder="Digite para filtrar (tipo, status, município…)"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyActivityOmniboxSuggestion({ state, suggestionId }))
}}
onRemoveChip={(chipId) => {
runAction(removeActivityOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearActivityOmnibox(state))
}}
/>
</form>
</div>
)
}

View file

@ -0,0 +1,87 @@
'use client'
import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
applySearchOnlyOmniboxSuggestion,
buildSearchOnlyOmniboxChips,
buildSearchOnlyOmniboxSuggestions,
clearSearchOnlyOmnibox,
removeSearchOnlyOmniboxChip,
type SearchOnlyOmniboxAction,
} from '@/lib/searchOnlyListOmnibox'
import { advisorListHrefForPage, type AdvisorListState } from '@/utilities/advisor/advisorListUrl'
export const AdvisorFilters = ({ state }: { state: AdvisorListState }) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => advisorListHrefForPage(next, 1),
})
const [query, setQuery] = useState('')
const withPageReset = (next: AdvisorListState): AdvisorListState => ({ ...next, page: 1 })
const chips = useMemo(() => buildSearchOnlyOmniboxChips(state), [state])
const suggestions = useMemo(() => buildSearchOnlyOmniboxSuggestions(query), [query])
const runAction = (action: SearchOnlyOmniboxAction<AdvisorListState>) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
return (
<form
role="search"
onSubmit={(event) => {
event.preventDefault()
}}
>
<CampaignListOmnibox
id="advisor-omnibox"
label="Buscar assessor por nome ou e-mail"
placeholder="Digite para buscar por nome ou e-mail…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId,
withPageReset,
}),
)
}}
onCommitQuery={(text) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId: `q:${text}`,
withPageReset,
}),
)
}}
onRemoveChip={(chipId) => {
runAction(removeSearchOnlyOmniboxChip({ state, chipId, withPageReset }))
}}
onClearAll={() => {
runAction(
clearSearchOnlyOmnibox({
state,
cleared: { page: 1 },
}),
)
}}
/>
</form>
)
}

View file

@ -0,0 +1,72 @@
'use client'
import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
applyDemandOmniboxSuggestion,
buildDemandOmniboxChips,
buildDemandOmniboxSuggestionSeeds,
clearDemandOmnibox,
filterDemandOmniboxSuggestions,
removeDemandOmniboxChip,
type DemandListState,
type DemandOmniboxAction,
} from '@/utilities/demand/demandOmnibox'
import { buildDemandListHref } from '@/utilities/demand/demandListUrl'
export const DemandFilters = ({ state }: { state: DemandListState }) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildDemandListHref(next, 1),
})
const [query, setQuery] = useState('')
const chips = useMemo(() => buildDemandOmniboxChips(state), [state])
const suggestionSeeds = useMemo(() => buildDemandOmniboxSuggestionSeeds(), [])
const suggestions = useMemo(
() => filterDemandOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: DemandOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
return (
<form
role="search"
onSubmit={(event) => {
event.preventDefault()
}}
>
<CampaignListOmnibox
id="demand-omnibox"
label="Filtrar demandas por status"
placeholder="Digite para filtrar por status…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyDemandOmniboxSuggestion({ state, suggestionId }))
}}
onRemoveChip={(chipId) => {
runAction(removeDemandOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearDemandOmnibox(state))
}}
/>
</form>
)
}

View file

@ -1,69 +1,99 @@
'use client'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import {
buildLeadershipFilterHref,
clearLeadershipListFilters,
formatLeadershipActiveFiltersSummary,
type LeadershipFilterOption,
} from '@/utilities/leadership/leadershipListFilters'
import { type LeadershipListState } from '@/utilities/leadership/leadershipListUrl'
import type { LeadershipListState } from '@/utilities/leadership/leadershipListUrl'
import {
applyLeadershipOmniboxSuggestion,
buildLeadershipOmniboxChips,
buildLeadershipOmniboxSuggestionSeeds,
clearLeadershipOmnibox,
filterLeadershipOmniboxSuggestions,
removeLeadershipOmniboxChip,
type LeadershipOmniboxAction,
} from '@/utilities/leadership/leadershipOmnibox'
export const LeadershipFilters = ({
state,
municipalityLabelsById,
municipalityFilterOptions,
}: {
state: LeadershipListState
/** Labels for the active municipality filter chips in the summary. */
municipalityLabelsById?: Readonly<Record<number, string>>
municipalityFilterOptions: LeadershipFilterOption[]
}) => {
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildLeadershipFilterHref })
const activeSummary = formatLeadershipActiveFiltersSummary(
{ ...state, q: draftQ },
municipalityLabelsById,
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildLeadershipFilterHref,
})
const [query, setQuery] = useState('')
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of municipalityFilterOptions) {
const id = Number(option.value)
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
}
return map
}, [municipalityFilterOptions])
const chips = useMemo(
() => buildLeadershipOmniboxChips({ state, municipalityLabelsById }),
[state, municipalityLabelsById],
)
const suggestionSeeds = useMemo(
() => buildLeadershipOmniboxSuggestionSeeds({ municipalityFilterOptions }),
[municipalityFilterOptions],
)
const suggestions = useMemo(
() => filterLeadershipOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: LeadershipOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(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="leadership-search"
label="Buscar liderança por nome"
placeholder="Buscar por nome…"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
{activeSummary}
</p>
) : null}
{activeSummary ? (
<Button
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => clearSearchAndNavigate(clearLeadershipListFilters(state))}
>
Limpar
</Button>
) : null}
</div>
{activeSummary ? (
<p className="text-sm text-muted-foreground md:hidden">{activeSummary}</p>
) : null}
<CampaignListOmnibox
id="leadership-omnibox"
label="Filtrar lideranças"
placeholder="Digite para filtrar (status, município, acesso…)"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyLeadershipOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(applyLeadershipOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removeLeadershipOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearLeadershipOmnibox(state))
}}
/>
</form>
)
}

View file

@ -1,26 +1,20 @@
'use client'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { buildTerritoryListHref, type TerritoryListState } from '@/utilities/territory/territoryListUrl'
import type { TerritoryFilterOption } from '@/utilities/territory/territoryListFilters'
import {
clearTerritoryListFilters,
formatTerritoryActiveFiltersSummary,
territoryCoverageLabels,
toggleTerritoryRegionFilter,
type TerritoryFilterOption,
} from '@/utilities/territory/territoryListFilters'
import {
buildTerritoryListHref,
parseTerritorySortValue,
resolveTerritoryListSort,
serializeTerritorySortValue,
territoryListSortOptions,
type TerritoryListState,
} from '@/utilities/territory/territoryListUrl'
applyTerritoryOmniboxSuggestion,
buildTerritoryOmniboxChips,
buildTerritoryOmniboxSuggestionSeeds,
clearTerritoryOmnibox,
filterTerritoryOmniboxSuggestions,
removeTerritoryOmniboxChip,
type TerritoryOmniboxAction,
} from '@/utilities/territory/territoryOmnibox'
export const TerritoryFilters = ({
state,
@ -29,105 +23,62 @@ export const TerritoryFilters = ({
state: TerritoryListState
regionOptions: TerritoryFilterOption[]
}) => {
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildTerritoryListHref })
const { sort, dir } = resolveTerritoryListSort(state)
const activeSummary = formatTerritoryActiveFiltersSummary({ ...state, q: draftQ })
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildTerritoryListHref,
})
const [query, setQuery] = useState('')
const chips = useMemo(() => buildTerritoryOmniboxChips(state), [state])
const suggestionSeeds = useMemo(
() => buildTerritoryOmniboxSuggestionSeeds(regionOptions),
[regionOptions],
)
const suggestions = useMemo(
() => filterTerritoryOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: TerritoryOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(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="territory-search"
label="Buscar território"
placeholder="Buscar por território…"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
{activeSummary}
</p>
) : null}
{activeSummary ? (
<Button
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => clearSearchAndNavigate(clearTerritoryListFilters(state))}
>
Limpar
</Button>
) : null}
</div>
<div className="flex flex-col gap-3 md:hidden">
{activeSummary ? <p className="text-sm text-muted-foreground">{activeSummary}</p> : null}
<CampaignMobileMultiFilterField
id="territory-filter-region"
label="Território"
emptyLabel="Todos"
options={regionOptions}
selected={state.regions ?? []}
onToggle={(value) => navigateWithSearch(toggleTerritoryRegionFilter(state, value))}
/>
<Field>
<FieldLabel htmlFor="territory-filter-coverage">Assessoria</FieldLabel>
<NativeSelect
id="territory-filter-coverage"
value={state.coverage ?? ''}
onChange={(event) =>
navigateWithSearch({
...state,
coverage:
event.target.value === 'com_assessor' || event.target.value === 'sem_assessor'
? event.target.value
: undefined,
})
}
className="w-full [&_select]:h-11"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
{Object.entries(territoryCoverageLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="territory-sort">Ordenar</FieldLabel>
<NativeSelect
id="territory-sort"
value={serializeTerritorySortValue(sort, dir)}
onChange={(event) => {
const parsed = parseTerritorySortValue(event.target.value)
if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir })
}}
className="w-full [&_select]:h-11"
>
{territoryListSortOptions.map((option) => (
<NativeSelectOption
key={serializeTerritorySortValue(option.key, option.dir)}
value={serializeTerritorySortValue(option.key, option.dir)}
>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
<CampaignListOmnibox
id="territory-omnibox"
label="Filtrar territórios"
placeholder="Digite para filtrar (território, assessoria, ordenar…)"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyTerritoryOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(applyTerritoryOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removeTerritoryOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearTerritoryOmnibox(state))
}}
/>
</form>
)
}

View file

@ -0,0 +1,90 @@
'use client'
import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
applySearchOnlyOmniboxSuggestion,
buildSearchOnlyOmniboxChips,
buildSearchOnlyOmniboxSuggestions,
clearSearchOnlyOmnibox,
removeSearchOnlyOmniboxChip,
type SearchOnlyOmniboxAction,
} from '@/lib/searchOnlyListOmnibox'
import {
buildOrganizationListHref,
type OrganizationListState,
} from '@/utilities/organization/organizationListUrl'
export const OrganizationFilters = ({ state }: { state: OrganizationListState }) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildOrganizationListHref(next, 1),
})
const [query, setQuery] = useState('')
const withPageReset = (next: OrganizationListState): OrganizationListState => ({ ...next, page: 1 })
const chips = useMemo(() => buildSearchOnlyOmniboxChips(state), [state])
const suggestions = useMemo(() => buildSearchOnlyOmniboxSuggestions(query), [query])
const runAction = (action: SearchOnlyOmniboxAction<OrganizationListState>) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
return (
<form
role="search"
onSubmit={(event) => {
event.preventDefault()
}}
>
<CampaignListOmnibox
id="organization-omnibox"
label="Buscar organização por nome"
placeholder="Digite para buscar por nome…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId,
withPageReset,
}),
)
}}
onCommitQuery={(text) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId: `q:${text}`,
withPageReset,
}),
)
}}
onRemoveChip={(chipId) => {
runAction(removeSearchOnlyOmniboxChip({ state, chipId, withPageReset }))
}}
onClearAll={() => {
runAction(
clearSearchOnlyOmnibox({
state,
cleared: withPageReset({ page: 1, ...(state.kind ? { kind: state.kind } : {}) }),
}),
)
}}
/>
</form>
)
}

View file

@ -12,6 +12,14 @@ import { normalizedText } from '@/utilities/campaignListUrl'
*/
export const SEARCH_DEBOUNCE_MS = 1000
type StateWithOptionalQ = { q?: string }
const readCommittedQ = (state: object): string | undefined =>
'q' in state ? (state as StateWithOptionalQ).q : undefined
const withDraftQ = <State extends object>(state: State, q: string): State =>
({ ...state, q }) as State
/**
* Navigation scaffold shared by the list filter shells that still use a
* debounced search box (territórios, dobradinhas). Municípios moved to the
@ -23,7 +31,7 @@ export const SEARCH_DEBOUNCE_MS = 1000
* keystrokes are handed to `toHref` untrimmed for the same reason: trimming is
* the serializer's job, and doing it here too would be a second policy.
*/
export const useCampaignListFilterNavigation = <State extends { q?: string }>({
export const useCampaignListFilterNavigation = <State extends object>({
state,
toHref,
}: {
@ -35,7 +43,7 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
const router = useRouter()
const { isPending, startTransition } = useCampaignListTransition()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [search, setSearch] = useState(state.q ?? '')
const [search, setSearch] = useState(readCommittedQ(state) ?? '')
/**
* Follow the URL when the committed query changes under us — back/forward and
@ -44,10 +52,11 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
* the next filter touch would put it back (`navigateWithSearch` carries the
* box). A pending debounce wins: that text is the user's, not the URL's.
*/
const [committedQ, setCommittedQ] = useState(state.q)
if (state.q !== committedQ) {
setCommittedQ(state.q)
if (!debounceRef.current) setSearch(state.q ?? '')
const [committedQ, setCommittedQ] = useState(readCommittedQ(state))
const stateQ = readCommittedQ(state)
if (stateQ !== committedQ) {
setCommittedQ(stateQ)
if (!debounceRef.current) setSearch(stateQ ?? '')
}
/**
@ -92,7 +101,7 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
* showing the text.
*/
const navigateWithSearch = (next: State) => {
navigateTo({ ...next, q: search })
navigateTo(withDraftQ(next, search))
}
/**
@ -117,7 +126,7 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
setSearch(value)
clearDebounce()
debounceRef.current = setTimeout(() => {
navigateTo({ ...stateRef.current, q: value })
navigateTo(withDraftQ(stateRef.current, value))
}, SEARCH_DEBOUNCE_MS)
}
@ -130,7 +139,7 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
* summary does not flicker off during the debounce, before the results it
* describes have actually changed.
*/
draftQ: normalizedText(search) || state.q,
draftQ: normalizedText(search) || readCommittedQ(state),
isPending,
/**
* Navigate with the caller's state as-is (no draft-search merge). Used by

View file

@ -1,26 +1,23 @@
'use client'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import {
buildStateDeputyFilterHref,
buildStateDeputyPartyOptions,
clearStateDeputyListFilters,
formatStateDeputyActiveFiltersSummary,
toggleStateDeputyPartyFilter,
type StateDeputyFilterOption,
} from '@/utilities/stateDeputyListFilters'
import type { StateDeputyListState } from '@/utilities/stateDeputyListUrl'
import {
parseStateDeputySortValue,
resolveStateDeputyListSort,
serializeStateDeputySortValue,
stateDeputyListSortOptions,
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
applyStateDeputyOmniboxSuggestion,
buildStateDeputyOmniboxChips,
buildStateDeputyOmniboxSuggestionSeeds,
clearStateDeputyOmnibox,
filterStateDeputyOmniboxSuggestions,
removeStateDeputyOmniboxChip,
type StateDeputyOmniboxAction,
} from '@/utilities/stateDeputyOmnibox'
export const StateDeputyFilters = ({
state,
@ -28,86 +25,65 @@ export const StateDeputyFilters = ({
hasNoParty,
}: {
state: StateDeputyListState
/** Distinct party names present under the current search (server-computed facet). */
partyOptions: StateDeputyFilterOption[]
/** Whether at least one facet-matching row has no party — gates the "Sem partido" option. */
hasNoParty: boolean
}) => {
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildStateDeputyFilterHref })
const { sort, dir } = resolveStateDeputyListSort(state)
const mobilePartyOptions = buildStateDeputyPartyOptions(partyOptions, hasNoParty)
const activeSummary = formatStateDeputyActiveFiltersSummary({ ...state, q: draftQ })
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildStateDeputyFilterHref,
})
const [query, setQuery] = useState('')
const chips = useMemo(() => buildStateDeputyOmniboxChips(state), [state])
const suggestionSeeds = useMemo(
() => buildStateDeputyOmniboxSuggestionSeeds({ partyOptions, hasNoParty }),
[partyOptions, hasNoParty],
)
const suggestions = useMemo(
() => filterStateDeputyOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: StateDeputyOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(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="state-deputy-search"
label="Buscar dobradinha"
placeholder="Buscar por nome…"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
{activeSummary}
</p>
) : null}
{activeSummary ? (
<Button
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => clearSearchAndNavigate(clearStateDeputyListFilters(state))}
>
Limpar
</Button>
) : null}
</div>
<div className="flex flex-col gap-3 md:hidden">
{activeSummary ? <p className="text-sm text-muted-foreground">{activeSummary}</p> : null}
<CampaignMobileMultiFilterField
id="state-deputy-filter-party"
label="Partido"
emptyLabel="Todos"
options={mobilePartyOptions}
selected={state.parties ?? []}
onToggle={(value) => navigateWithSearch(toggleStateDeputyPartyFilter(state, value))}
/>
<Field>
<FieldLabel htmlFor="state-deputy-sort">Ordenar</FieldLabel>
<NativeSelect
id="state-deputy-sort"
value={serializeStateDeputySortValue(sort, dir)}
onChange={(event) => {
const parsed = parseStateDeputySortValue(event.target.value)
if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir })
}}
className="w-full [&_select]:h-11"
>
{stateDeputyListSortOptions.map((option) => (
<NativeSelectOption
key={serializeStateDeputySortValue(option.key, option.dir)}
value={serializeStateDeputySortValue(option.key, option.dir)}
>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
<CampaignListOmnibox
id="state-deputy-omnibox"
label="Filtrar dobradinhas"
placeholder="Digite para filtrar (partido, ordenar…)"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyStateDeputyOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(applyStateDeputyOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removeStateDeputyOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearStateDeputyOmnibox(state))
}}
/>
</form>
)
}

View file

@ -1,37 +1,21 @@
'use client'
import { useEffect, useRef } from 'react'
import { useMemo, useState } from 'react'
import { CampaignCollapsibleFilterPanel } from '@/components/campaign/shared/CampaignCollapsibleFilterPanel'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { useCampaignFilterValues } from '@/components/campaign/shared/useCampaignFilterValues'
import { SEARCH_DEBOUNCE_MS } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import {
buildSupporterListSearchParams,
supporterVoteIntentionLabels,
type SupporterListState,
} from '@/utilities/supporter/supporterUi'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { municipalityComboboxOptions } from '@/utilities/territory/territoryComboboxOptions'
type FilterValues = {
q: string
voteIntention: string
city: string
municipality: string
}
const valuesFromState = (state: SupporterListState): FilterValues => ({
q: state.q ?? '',
voteIntention: state.voteIntention ?? '',
city: state.city ?? '',
municipality: state.municipality ? String(state.municipality) : '',
})
const filterNames = ['voteIntention', 'city', 'municipality'] as const
import {
applySupporterOmniboxSuggestion,
buildSupporterOmniboxChips,
buildSupporterOmniboxSuggestionSeeds,
clearSupporterOmnibox,
filterSupporterOmniboxSuggestions,
removeSupporterOmniboxChip,
type SupporterOmniboxAction,
} from '@/utilities/supporter/supporterOmnibox'
import { buildSupporterListHref, type SupporterListState } from '@/utilities/supporter/supporterUi'
export const SupporterFilters = ({
state,
@ -40,126 +24,91 @@ export const SupporterFilters = ({
state: SupporterListState
municipalityOptions: RelationOption[]
}) => {
const { values, isPending, updateValues, setLocalValues } = useCampaignFilterValues({
committedValues: valuesFromState(state),
toHref: (next) => {
const params = buildSupporterListSearchParams({
page: 1,
q: next.q,
voteIntention: next.voteIntention as SupporterListState['voteIntention'],
city: next.city,
municipality: next.municipality ? Number(next.municipality) : undefined,
})
const query = params.toString()
return query ? `/campanha/apoiadores?${query}` : '/campanha/apoiadores'
},
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildSupporterListHref(next, 1),
})
const [query, setQuery] = useState('')
/**
* Search-as-you-type (P3-F unified idiom — this shell used to require an
* explicit submit): the keystroke mirrors locally and the debounce commits,
* so a re-typed identical value still cancels the pending navigation through
* the hook's no-op guard instead of firing a second round-trip.
*/
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
},
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of municipalityOptions) {
if (Number.isSafeInteger(option.id) && option.id > 0) map.set(option.id, option.name)
}
return map
}, [municipalityOptions])
const cityOptions = useMemo(
() => municipalityComboboxOptions().map((option) => ({ value: option.value, label: option.label })),
[],
)
const onSearchChange = (value: string) => {
setLocalValues((current) => ({ ...current, q: value }))
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
updateValues((current) => ({ ...current, q: value.trim() }))
}, SEARCH_DEBOUNCE_MS)
const municipalityFilterOptions = useMemo(
() =>
municipalityOptions.map((option) => ({
value: String(option.id),
label: option.name,
})),
[municipalityOptions],
)
const chips = useMemo(
() => buildSupporterOmniboxChips({ state, municipalityLabelsById }),
[state, municipalityLabelsById],
)
const suggestionSeeds = useMemo(
() =>
buildSupporterOmniboxSuggestionSeeds({
cityOptions,
municipalityOptions: municipalityFilterOptions,
}),
[cityOptions, municipalityFilterOptions],
)
const suggestions = useMemo(
() => filterSupporterOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: SupporterOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
return
}
navigate(action.state)
}
const clearFilters = () =>
updateValues((current) => ({
...current,
...(Object.fromEntries(filterNames.map((name) => [name, ''])) as Partial<FilterValues>),
}))
const hasFilters = filterNames.some((name) => Boolean(values[name]))
return (
<div
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
<form
role="search"
onSubmit={(event) => {
event.preventDefault()
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div role="search">
<CampaignSearchInput
id="supporter-search"
label="Buscar por nome, telefone ou município"
name="q"
value={values.q}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Buscar por nome, telefone ou município"
enterKeyHint="search"
/>
</div>
<CampaignCollapsibleFilterPanel
panelId="supporter-filter-controls"
hasFilters={hasFilters}
onClear={clearFilters}
>
<div className="grid gap-4 lg:grid-cols-3">
<Field>
<FieldLabel htmlFor="supporter-voteIntention">Intenção de voto</FieldLabel>
<NativeSelect
id="supporter-voteIntention"
name="voteIntention"
value={values.voteIntention}
onChange={(event) =>
updateValues((current) => ({ ...current, voteIntention: event.target.value }))
}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
{Object.entries(supporterVoteIntentionLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="supporter-city">Cidade</FieldLabel>
<StrictCombobox
id="supporter-city"
options={municipalityComboboxOptions()}
value={values.city}
onValueChange={(city) => updateValues((current) => ({ ...current, city }))}
/>
</Field>
<Field>
<FieldLabel htmlFor="supporter-municipality">Município</FieldLabel>
<NativeSelect
id="supporter-municipality"
name="municipality"
value={values.municipality}
onChange={(event) =>
updateValues((current) => ({ ...current, municipality: event.target.value }))
}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
{municipalityOptions.map((municipality) => (
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
{municipality.name}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
</CampaignCollapsibleFilterPanel>
</div>
<CampaignListOmnibox
id="supporter-omnibox"
label="Filtrar apoiadores"
placeholder="Digite para filtrar (nome, intenção, cidade, município…)"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applySupporterOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(applySupporterOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removeSupporterOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearSupporterOmnibox(state))
}}
/>
</form>
)
}

View file

@ -31,3 +31,68 @@ const omniboxQueryMatches = (
export const omniboxGroupMatches = (group: string, needle: string): boolean =>
omniboxQueryMatches(group, needle)
export const SUGGESTION_CAP_PER_GROUP = 8
export type OmniboxSuggestionSeed = CampaignListOmniboxSuggestion & {
/** When true, show even with an empty query (dimension shortcuts). */
emptyQueryVisible?: boolean
normalizedLabel: string
normalizedKeywords: readonly string[]
}
export const createOmniboxSuggestionSeed = (
suggestion: CampaignListOmniboxSuggestion,
extras?: { emptyQueryVisible?: boolean },
): OmniboxSuggestionSeed => ({
...suggestion,
...extras,
normalizedLabel: normalizeSearchPhrase(suggestion.label),
normalizedKeywords: (suggestion.keywords ?? []).map((keyword) => normalizeSearchPhrase(keyword)),
})
export const filterOmniboxSuggestionSeeds = (
seeds: readonly OmniboxSuggestionSeed[],
query: string,
): CampaignListOmniboxSuggestion[] => {
const suggestions: CampaignListOmniboxSuggestion[] = []
const trimmed = query.trim()
const normalizedNeedle = normalizeSearchPhrase(trimmed)
if (trimmed) {
suggestions.push({
id: `q:${trimmed}`,
group: 'Busca',
label: `Busca: ${trimmed}`,
keywords: ['busca', 'pesquisar', 'texto'],
})
}
const groupCounts = new Map<string, number>()
for (const seed of seeds) {
const count = groupCounts.get(seed.group) ?? 0
if (count >= SUGGESTION_CAP_PER_GROUP) continue
if (!normalizedNeedle) {
if (!seed.emptyQueryVisible) continue
} else {
const groupHit = omniboxGroupMatches(seed.group, trimmed)
const labelHit =
seed.normalizedLabel.includes(normalizedNeedle) ||
seed.normalizedKeywords.some((keyword) => keyword.includes(normalizedNeedle))
if (!groupHit && !labelHit) continue
}
const {
emptyQueryVisible: _visible,
normalizedLabel: _label,
normalizedKeywords: _kw,
...suggestion
} = seed
suggestions.push(suggestion)
groupCounts.set(seed.group, count + 1)
}
return suggestions
}

View file

@ -0,0 +1,68 @@
/**
* Degenerate omnibox for lists that only expose free-text search (B128:
* organizações, assessores). Pure / client-safe.
*/
import {
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
export type SearchOnlyListState = {
q?: string
page?: number
}
export type SearchOnlyOmniboxAction<T extends SearchOnlyListState> =
| { kind: 'url'; state: T }
| { kind: 'clear'; state: T }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
export const buildSearchOnlyOmniboxChips = (state: SearchOnlyListState): CampaignListOmniboxChip[] =>
state.q ? [{ id: 'q', label: chipLabel('Busca', state.q) }] : []
export const buildSearchOnlyOmniboxSuggestions = (
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds([], query)
export const applySearchOnlyOmniboxSuggestion = <T extends SearchOnlyListState>({
state,
suggestionId,
withPageReset,
}: {
state: T
suggestionId: string
withPageReset: (next: T) => T
}): SearchOnlyOmniboxAction<T> => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
return { kind: 'url', state }
}
export const removeSearchOnlyOmniboxChip = <T extends SearchOnlyListState>({
state,
chipId,
withPageReset,
}: {
state: T
chipId: string
withPageReset: (next: T) => T
}): SearchOnlyOmniboxAction<T> => {
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
}
return { kind: 'url', state }
}
export const clearSearchOnlyOmnibox = <T extends SearchOnlyListState>({
cleared,
}: {
state: T
cleared: T
}): SearchOnlyOmniboxAction<T> => ({
kind: 'clear',
state: cleared,
})

View file

@ -0,0 +1,210 @@
/**
* Activity list omnibox adapter (B128). Pure / client-safe.
* Tab preset stays outside the omnibox (product map).
*/
import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity'
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
buildActivityListSearchParams,
parseActivityListParams,
type ActivityListState,
type ActivityTab,
} from '@/utilities/activityUi'
export type ActivityFilterOption = {
value: string
label: string
}
export type ActivityOmniboxAction =
| { kind: 'url'; state: ActivityListState }
| { kind: 'clear'; state: ActivityListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: ActivityListState): ActivityListState => ({ ...state, page: 1 })
const parseStateFromParams = (raw: Record<string, string | string[] | undefined>): ActivityListState =>
parseActivityListParams(raw)
const setExclusiveField = (
state: ActivityListState,
field: 'kind' | 'status' | 'municipality',
value: string | undefined,
): ActivityListState => {
const params = buildActivityListSearchParams(withPageReset(state))
const raw: Record<string, string | string[] | undefined> = Object.fromEntries(params.entries())
if (field === 'kind') {
raw.kind = value
} else if (field === 'status') {
raw.status = value
} else {
raw.municipality = value
}
return parseStateFromParams(raw)
}
export const buildActivityOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: ActivityListState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.kind) {
chips.push({
id: `kind:${state.kind}`,
label: chipLabel('Tipo', activityKindLabels[state.kind]),
})
}
if (state.tab === 'todos' && state.status) {
chips.push({
id: `status:${state.status}`,
label: chipLabel('Status', activityStatusLabels[state.status]),
})
}
if (state.municipality) {
chips.push({
id: `municipality:${state.municipality}`,
label: chipLabel(
'Município',
municipalityLabelsById.get(state.municipality) ?? `Município #${state.municipality}`,
),
})
}
return chips
}
export const buildActivityOmniboxSuggestionSeeds = ({
tab,
municipalityOptions,
}: {
tab: ActivityTab
municipalityOptions: readonly ActivityFilterOption[]
}) => {
const seeds = []
for (const [value, label] of Object.entries(activityKindLabels)) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `kind:${value}`,
group: 'Tipo',
label,
keywords: ['tipo', 'atividade'],
},
{ emptyQueryVisible: true },
),
)
}
if (tab === 'todos') {
for (const [value, label] of Object.entries(activityStatusLabels)) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `status:${value}`,
group: 'Status',
label,
keywords: ['status'],
},
{ emptyQueryVisible: true },
),
)
}
}
for (const option of municipalityOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio'],
}),
)
}
return seeds
}
export const filterActivityOmniboxSuggestions = (
seeds: ReturnType<typeof buildActivityOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyActivityOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: ActivityListState
suggestionId: string
}): ActivityOmniboxAction => {
if (suggestionId.startsWith('kind:')) {
const value = suggestionId.slice(5)
const next =
state.kind === value
? setExclusiveField(state, 'kind', undefined)
: setExclusiveField(state, 'kind', value)
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('status:')) {
const value = suggestionId.slice(7)
const next =
state.status === value
? setExclusiveField(state, 'status', undefined)
: setExclusiveField(state, 'status', value)
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('municipality:')) {
const value = suggestionId.slice(13)
const next =
state.municipality === Number(value)
? setExclusiveField(state, 'municipality', undefined)
: setExclusiveField(state, 'municipality', value)
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
export const removeActivityOmniboxChip = ({
state,
chipId,
}: {
state: ActivityListState
chipId: string
}): ActivityOmniboxAction => {
if (chipId.startsWith('kind:')) {
return { kind: 'url', state: setExclusiveField(state, 'kind', undefined) }
}
if (chipId.startsWith('status:')) {
return { kind: 'url', state: setExclusiveField(state, 'status', undefined) }
}
if (chipId.startsWith('municipality:')) {
return { kind: 'url', state: setExclusiveField(state, 'municipality', undefined) }
}
return { kind: 'url', state }
}
export const clearActivityOmnibox = (state: ActivityListState): ActivityOmniboxAction => ({
kind: 'clear',
state: withPageReset({ page: 1, tab: state.tab }),
})

View file

@ -0,0 +1,33 @@
import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export type AdvisorListState = {
page: number
q?: string
}
export const parseAdvisorListParams = (searchParams: RawSearchParams): AdvisorListState => {
const q = normalizedText(firstValue(searchParams.q))
const page = strictDecimalInteger(firstValue(searchParams.page)) ?? 1
return {
page,
...(q ? { q } : {}),
}
}
const buildAdvisorListSearchParams = (state: AdvisorListState, page = state.page) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
return params
}
export const advisorListHrefForPage = (state: AdvisorListState, page: number): string =>
buildListHref(state, buildAdvisorListSearchParams, CAMPAIGN_ADVISORS_HOME, page)

View file

@ -4,20 +4,13 @@ import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
advisorListHrefForPage,
parseAdvisorListParams,
type AdvisorListState,
} from '@/utilities/advisor/advisorListUrl'
const advisorPageSize = 25
const advisorListBasePath = '/campanha/assessores'
export type AdvisorListState = {
page: number
q?: string
}
export type { AdvisorListState }
export { advisorListHrefForPage, parseAdvisorListParams }
type AdvisorMunicipalityViewModel = {
id: number
@ -45,25 +38,7 @@ export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
municipalities: AdvisorMunicipalityViewModel[]
}
export const parseAdvisorListParams = (searchParams: RawSearchParams): AdvisorListState => {
const q = normalizedText(firstValue(searchParams.q))
const page = strictDecimalInteger(firstValue(searchParams.page)) ?? 1
return {
page,
...(q ? { q } : {}),
}
}
const buildAdvisorListSearchParams = (state: AdvisorListState, page = state.page) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
return params
}
export const advisorListHrefForPage = (state: AdvisorListState, page: number): string =>
buildListHref(state, buildAdvisorListSearchParams, advisorListBasePath, page)
const advisorPageSize = 25
/** Fan a município out to each of the advisors the caller asked about. */
const collectByAdvisor = <T>(

View file

@ -4,21 +4,22 @@ import type { Payload } from 'payload'
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import type { Activity, CampaignDemand, CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildListHref,
firstValue,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
buildDemandListHref,
parseDemandListParams,
type DemandListState,
} from '@/utilities/demand/demandListUrl'
import {
loadCampaignUserNamesByIds,
loadLeadershipContactNamesByIds,
loadMunicipalityLabelsByIds,
} from '@/utilities/loadNamesByIds'
export type { DemandListState }
export { buildDemandListHref, parseDemandListParams }
const demandPageSize = 25
export type DemandRowViewModel = {
@ -34,45 +35,6 @@ export type DemandRowViewModel = {
createdAt: string
}
export type DemandListState = {
page: number
status?: CampaignDemandStatus
kind?: CampaignDemandKind
activityId?: number
}
export const parseDemandListParams = (searchParams: RawSearchParams): DemandListState => {
const rawStatus = firstValue(searchParams.status)
const rawKind = firstValue(searchParams.kind)
const activityId = strictDecimalInteger(firstValue(searchParams.activity))
return {
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)
? { status: rawStatus as CampaignDemandStatus }
: {}),
...(campaignDemandKinds.includes(rawKind as CampaignDemandKind)
? { kind: rawKind as CampaignDemandKind }
: {}),
...(activityId ? { activityId } : {}),
}
}
const buildDemandListSearchParams = (
state: DemandListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.status) params.set('status', state.status)
if (state.kind) params.set('kind', state.kind)
if (state.activityId) params.set('activity', String(state.activityId))
if (page > 1) params.set('page', String(page))
return params
}
export const buildDemandListHref = (state: DemandListState, page: number): string =>
buildListHref(state, buildDemandListSearchParams, '/campanha/demandas', page)
const resolveMunicipalityAndRequesterNames = async (
payload: Payload,
demands: CampaignDemand[],

View file

@ -0,0 +1,44 @@
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import {
buildListHref,
firstValue,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export type DemandListState = {
page: number
status?: CampaignDemandStatus
kind?: CampaignDemandKind
activityId?: number
}
export const parseDemandListParams = (searchParams: RawSearchParams): DemandListState => {
const rawStatus = firstValue(searchParams.status)
const rawKind = firstValue(searchParams.kind)
const activityId = strictDecimalInteger(firstValue(searchParams.activity))
return {
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)
? { status: rawStatus as CampaignDemandStatus }
: {}),
...(campaignDemandKinds.includes(rawKind as CampaignDemandKind)
? { kind: rawKind as CampaignDemandKind }
: {}),
...(activityId ? { activityId } : {}),
}
}
const buildDemandListSearchParams = (state: DemandListState, page = state.page): URLSearchParams => {
const params = new URLSearchParams()
if (state.status) params.set('status', state.status)
if (state.kind) params.set('kind', state.kind)
if (state.activityId) params.set('activity', String(state.activityId))
if (page > 1) params.set('page', String(page))
return params
}
export const buildDemandListHref = (state: DemandListState, page: number): string =>
buildListHref(state, buildDemandListSearchParams, '/campanha/demandas', page)

View file

@ -0,0 +1,100 @@
/**
* Demand list omnibox adapter (B128). Status-only; kind/activity deep-links
* stay URL-only without new list UI. Pure / client-safe.
*/
import {
campaignDemandStatusLabels,
campaignDemandStatuses,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import type { DemandListState } from '@/utilities/demand/demandListUrl'
export type { DemandListState }
export type DemandOmniboxAction =
| { kind: 'url'; state: DemandListState }
| { kind: 'clear'; state: DemandListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: DemandListState): DemandListState => ({ ...state, page: 1 })
export const buildDemandOmniboxChips = (state: DemandListState): CampaignListOmniboxChip[] => {
if (!state.status) return []
return [
{
id: `status:${state.status}`,
label: chipLabel('Status', campaignDemandStatusLabels[state.status]),
},
]
}
export const buildDemandOmniboxSuggestionSeeds = () =>
campaignDemandStatuses.map((status) =>
createOmniboxSuggestionSeed(
{
id: `status:${status}`,
group: 'Status',
label: campaignDemandStatusLabels[status],
keywords: ['status', 'situacao'],
},
{ emptyQueryVisible: true },
),
)
export const filterDemandOmniboxSuggestions = (
seeds: ReturnType<typeof buildDemandOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyDemandOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: DemandListState
suggestionId: string
}): DemandOmniboxAction => {
if (!suggestionId.startsWith('status:')) return { kind: 'url', state }
const value = suggestionId.slice(7)
if (!campaignDemandStatuses.includes(value as CampaignDemandStatus)) {
return { kind: 'url', state }
}
const status = value as CampaignDemandStatus
return {
kind: 'url',
state: withPageReset({
...state,
status: state.status === status ? undefined : status,
}),
}
}
export const removeDemandOmniboxChip = ({
state,
chipId,
}: {
state: DemandListState
chipId: string
}): DemandOmniboxAction => {
if (chipId.startsWith('status:')) {
return { kind: 'url', state: withPageReset({ ...state, status: undefined }) }
}
return { kind: 'url', state }
}
export const clearDemandOmnibox = (state: DemandListState): DemandOmniboxAction => ({
kind: 'clear',
state: withPageReset({
page: 1,
...(state.kind ? { kind: state.kind } : {}),
...(state.activityId ? { activityId: state.activityId } : {}),
}),
})

View file

@ -0,0 +1,263 @@
/**
* Leadership list omnibox adapter (B128). Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
clearLeadershipListFilters,
leadershipAccessFilterOptions,
leadershipStatusFilterOptions,
toggleLeadershipAccessFilter,
toggleLeadershipMunicipalityFilter,
toggleLeadershipStatusFilter,
type LeadershipFilterOption,
} from '@/utilities/leadership/leadershipListFilters'
import {
leadershipListSortLabels,
resolveLeadershipListSort,
type LeadershipListSortDirection,
type LeadershipListSortKey,
type LeadershipListState,
} from '@/utilities/leadership/leadershipListUrl'
import {
leadershipAccessFilterLabels,
supportStatusLabels,
} from '@/utilities/leadership/leadershipLabels'
const DEFAULT_LEADERSHIP_LIST_SORT_KEY: LeadershipListSortKey = 'updatedAt'
const DEFAULT_LEADERSHIP_LIST_SORT_DIR: LeadershipListSortDirection = 'desc'
export type LeadershipOmniboxAction =
| { kind: 'url'; state: LeadershipListState }
| { kind: 'clear'; state: LeadershipListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: LeadershipListState): LeadershipListState => ({ ...state, page: 1 })
const isDefaultLeadershipListSort = (state: LeadershipListState): boolean => {
const { sort, dir } = resolveLeadershipListSort(state)
return sort === DEFAULT_LEADERSHIP_LIST_SORT_KEY && dir === DEFAULT_LEADERSHIP_LIST_SORT_DIR
}
const leadershipListSortOptions = (
Object.keys(leadershipListSortLabels) as LeadershipListSortKey[]
).flatMap((key) =>
(['asc', 'desc'] as const).map((dir) => ({
key,
dir,
label:
key === 'updatedAt'
? dir === 'desc'
? 'Última atualização (mais recente)'
: 'Última atualização (mais antigo)'
: dir === 'asc'
? `${leadershipListSortLabels[key]} (A–Z)`
: `${leadershipListSortLabels[key]} (Z–A)`,
})),
)
export const buildLeadershipOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: LeadershipListState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
for (const status of state.statuses ?? []) {
chips.push({ id: `status:${status}`, label: chipLabel('Status', supportStatusLabels[status]) })
}
for (const municipalityId of state.municipalities ?? []) {
chips.push({
id: `municipality:${municipalityId}`,
label: chipLabel(
'Município',
municipalityLabelsById.get(municipalityId) ?? `Município #${municipalityId}`,
),
})
}
if (state.access) {
chips.push({
id: `access:${state.access}`,
label: chipLabel('Acesso ao app', leadershipAccessFilterLabels[state.access]),
})
}
if (!isDefaultLeadershipListSort(state)) {
const { sort, dir } = resolveLeadershipListSort(state)
const option = leadershipListSortOptions.find((entry) => entry.key === sort && entry.dir === dir)
chips.push({
id: 'sort',
label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`),
})
}
return chips
}
export const buildLeadershipOmniboxSuggestionSeeds = ({
municipalityFilterOptions,
}: {
municipalityFilterOptions: readonly LeadershipFilterOption[]
}) => {
const seeds = []
for (const option of leadershipStatusFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `status:${option.value}`,
group: 'Status',
label: option.label,
keywords: ['status', 'situacao'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of municipalityFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio'],
}),
)
}
for (const option of leadershipAccessFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `access:${option.value}`,
group: 'Acesso ao app',
label: option.label,
keywords: ['acesso', 'app', 'login'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of leadershipListSortOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `sort:${option.key}|${option.dir}`,
group: 'Ordenação',
label: option.label,
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
}),
)
}
return seeds
}
export const filterLeadershipOmniboxSuggestions = (
seeds: ReturnType<typeof buildLeadershipOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyLeadershipOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: LeadershipListState
suggestionId: string
}): LeadershipOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (suggestionId.startsWith('status:')) {
return {
kind: 'url',
state: toggleLeadershipStatusFilter(state, suggestionId.slice(7)),
}
}
if (suggestionId.startsWith('municipality:')) {
return {
kind: 'url',
state: toggleLeadershipMunicipalityFilter(state, suggestionId.slice(13)),
}
}
if (suggestionId.startsWith('access:')) {
return {
kind: 'url',
state: toggleLeadershipAccessFilter(state, suggestionId.slice(7)),
}
}
if (suggestionId.startsWith('sort:')) {
const raw = suggestionId.slice(5)
const [key, dir] = raw.split('|')
const option = leadershipListSortOptions.find((entry) => entry.key === key && entry.dir === dir)
if (!option) return { kind: 'url', state }
const next = withPageReset({ ...state, sort: option.key, dir: option.dir })
if (isDefaultLeadershipListSort(next)) {
return {
kind: 'url',
state: withPageReset({ ...state, sort: undefined, dir: undefined }),
}
}
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
export const removeLeadershipOmniboxChip = ({
state,
chipId,
}: {
state: LeadershipListState
chipId: string
}): LeadershipOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
if (chipId === 'sort') {
return {
kind: 'url',
state: withPageReset({ ...state, sort: undefined, dir: undefined }),
}
}
if (chipId.startsWith('status:')) {
return {
kind: 'url',
state: toggleLeadershipStatusFilter(state, chipId.slice(7)),
}
}
if (chipId.startsWith('municipality:')) {
return {
kind: 'url',
state: toggleLeadershipMunicipalityFilter(state, chipId.slice(13)),
}
}
if (chipId.startsWith('access:')) {
return { kind: 'url', state: toggleLeadershipAccessFilter(state, chipId.slice(7)) }
}
return { kind: 'url', state }
}
export const clearLeadershipOmnibox = (state: LeadershipListState): LeadershipOmniboxAction => ({
kind: 'clear',
state: clearLeadershipListFilters(state),
})

View file

@ -0,0 +1,44 @@
import type { OrganizationKind } from '@/lib/schemas/organization'
import { organizationKinds } from '@/lib/schemas/organization'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export type OrganizationListState = {
page: number
q?: string
kind?: OrganizationKind
}
export const parseOrganizationListParams = (
searchParams: RawSearchParams,
): OrganizationListState => {
const q = normalizedText(firstValue(searchParams.q))
const rawKind = firstValue(searchParams.kind)
return {
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(q ? { q } : {}),
...(organizationKinds.includes(rawKind as OrganizationKind)
? { kind: rawKind as OrganizationKind }
: {}),
}
}
const buildOrganizationListSearchParams = (
state: OrganizationListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
return params
}
export const buildOrganizationListHref = (state: OrganizationListState, page: number): string =>
buildListHref(state, buildOrganizationListSearchParams, '/campanha/organizacoes', page)

View file

@ -4,18 +4,18 @@ import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import type { OrganizationKind } from '@/lib/schemas/organization'
import { organizationKinds } from '@/lib/schemas/organization'
import type { CampaignUser, Organization } from '@/payload-types'
import type { ActivityStatus } from '@/utilities/activityUi'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
buildOrganizationListHref,
parseOrganizationListParams,
type OrganizationListState,
} from '@/utilities/organization/organizationListUrl'
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
export type { OrganizationListState }
export { buildOrganizationListHref, parseOrganizationListParams }
const organizationPageSize = 25
export type OrganizationRowViewModel = {
@ -27,41 +27,6 @@ export type OrganizationRowViewModel = {
leadershipCount: number
}
export type OrganizationListState = {
page: number
q?: string
kind?: OrganizationKind
}
export const parseOrganizationListParams = (
searchParams: RawSearchParams,
): OrganizationListState => {
const q = normalizedText(firstValue(searchParams.q))
const rawKind = firstValue(searchParams.kind)
return {
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(q ? { q } : {}),
...(organizationKinds.includes(rawKind as OrganizationKind)
? { kind: rawKind as OrganizationKind }
: {}),
}
}
const buildOrganizationListSearchParams = (
state: OrganizationListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
return params
}
export const buildOrganizationListHref = (state: OrganizationListState, page: number): string =>
buildListHref(state, buildOrganizationListSearchParams, '/campanha/organizacoes', page)
const municipalityNamesByIds = async (
payload: Payload,
ids: number[],

View file

@ -0,0 +1,173 @@
/**
* State deputy ("dobradinha") list omnibox adapter (B128). Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
buildStateDeputyPartyOptions,
clearStateDeputyListFilters,
toggleStateDeputyPartyFilter,
type StateDeputyFilterOption,
} from '@/utilities/stateDeputyListFilters'
import {
NO_PARTY_FILTER_VALUE,
resolveStateDeputyListSort,
stateDeputyListSortOptions,
type StateDeputyListSortDirection,
type StateDeputyListSortKey,
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
const DEFAULT_STATE_DEPUTY_LIST_SORT_KEY: StateDeputyListSortKey = 'name'
const DEFAULT_STATE_DEPUTY_LIST_SORT_DIR: StateDeputyListSortDirection = 'asc'
const noPartyFilterLabel = 'Sem partido'
export type StateDeputyOmniboxAction =
| { kind: 'url'; state: StateDeputyListState }
| { kind: 'clear'; state: StateDeputyListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: StateDeputyListState): StateDeputyListState => ({ ...state, page: 1 })
const isDefaultStateDeputyListSort = (state: StateDeputyListState): boolean => {
const { sort, dir } = resolveStateDeputyListSort(state)
return sort === DEFAULT_STATE_DEPUTY_LIST_SORT_KEY && dir === DEFAULT_STATE_DEPUTY_LIST_SORT_DIR
}
const partyChipLabel = (party: string): string =>
party === NO_PARTY_FILTER_VALUE ? noPartyFilterLabel : party
export const buildStateDeputyOmniboxChips = (state: StateDeputyListState): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
for (const party of state.parties ?? []) {
chips.push({ id: `party:${party}`, label: chipLabel('Partido', partyChipLabel(party)) })
}
if (!isDefaultStateDeputyListSort(state)) {
const { sort, dir } = resolveStateDeputyListSort(state)
const option = stateDeputyListSortOptions.find(
(entry) => entry.key === sort && entry.dir === dir,
)
chips.push({
id: 'sort',
label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`),
})
}
return chips
}
export const buildStateDeputyOmniboxSuggestionSeeds = ({
partyOptions,
hasNoParty,
}: {
partyOptions: readonly StateDeputyFilterOption[]
hasNoParty: boolean
}) => {
const seeds = []
for (const option of buildStateDeputyPartyOptions(partyOptions, hasNoParty)) {
seeds.push(
createOmniboxSuggestionSeed({
id: `party:${option.value}`,
group: 'Partido',
label: option.label,
keywords: ['partido'],
}),
)
}
for (const option of stateDeputyListSortOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `sort:${option.key}|${option.dir}`,
group: 'Ordenação',
label: option.label,
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
}),
)
}
return seeds
}
export const filterStateDeputyOmniboxSuggestions = (
seeds: ReturnType<typeof buildStateDeputyOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyStateDeputyOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: StateDeputyListState
suggestionId: string
}): StateDeputyOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (suggestionId.startsWith('party:')) {
return {
kind: 'url',
state: toggleStateDeputyPartyFilter(state, suggestionId.slice(6)),
}
}
if (suggestionId.startsWith('sort:')) {
const raw = suggestionId.slice(5)
const [key, dir] = raw.split('|')
const option = stateDeputyListSortOptions.find((entry) => entry.key === key && entry.dir === dir)
if (!option) return { kind: 'url', state }
const next = withPageReset({ ...state, sort: option.key, dir: option.dir })
if (isDefaultStateDeputyListSort(next)) {
return {
kind: 'url',
state: withPageReset({ ...state, sort: undefined, dir: undefined }),
}
}
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
export const removeStateDeputyOmniboxChip = ({
state,
chipId,
}: {
state: StateDeputyListState
chipId: string
}): StateDeputyOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
if (chipId === 'sort') {
return {
kind: 'url',
state: withPageReset({ ...state, sort: undefined, dir: undefined }),
}
}
if (chipId.startsWith('party:')) {
return {
kind: 'url',
state: toggleStateDeputyPartyFilter(state, chipId.slice(6)),
}
}
return { kind: 'url', state }
}
export const clearStateDeputyOmnibox = (state: StateDeputyListState): StateDeputyOmniboxAction => ({
kind: 'clear',
state: clearStateDeputyListFilters(state),
})

View file

@ -0,0 +1,209 @@
/**
* Supporter list omnibox adapter (B128). Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
buildSupporterListSearchParams,
parseSupporterListParams,
supporterVoteIntentionLabels,
type SupporterListState,
} from '@/utilities/supporter/supporterUi'
export type SupporterFilterOption = {
value: string
label: string
}
export type SupporterOmniboxAction =
| { kind: 'url'; state: SupporterListState }
| { kind: 'clear'; state: SupporterListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: SupporterListState): SupporterListState => ({ ...state, page: 1 })
const parseStateFromParams = (raw: Record<string, string | string[] | undefined>): SupporterListState =>
parseSupporterListParams(raw)
export const buildSupporterOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: SupporterListState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
if (state.voteIntention) {
chips.push({
id: `voteIntention:${state.voteIntention}`,
label: chipLabel('Intenção de voto', supporterVoteIntentionLabels[state.voteIntention]),
})
}
if (state.city) {
chips.push({ id: `city:${state.city}`, label: chipLabel('Cidade', state.city) })
}
if (state.municipality) {
chips.push({
id: `municipality:${state.municipality}`,
label: chipLabel(
'Município',
municipalityLabelsById.get(state.municipality) ?? `Município #${state.municipality}`,
),
})
}
return chips
}
export const buildSupporterOmniboxSuggestionSeeds = ({
cityOptions,
municipalityOptions,
}: {
cityOptions: readonly SupporterFilterOption[]
municipalityOptions: readonly SupporterFilterOption[]
}) => {
const seeds = []
for (const [value, label] of Object.entries(supporterVoteIntentionLabels)) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `voteIntention:${value}`,
group: 'Intenção de voto',
label,
keywords: ['intencao', 'voto'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of cityOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `city:${option.value}`,
group: 'Cidade',
label: option.label,
keywords: ['cidade'],
}),
)
}
for (const option of municipalityOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio'],
}),
)
}
return seeds
}
export const filterSupporterOmniboxSuggestions = (
seeds: ReturnType<typeof buildSupporterOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
const setExclusiveField = (
state: SupporterListState,
field: 'voteIntention' | 'city' | 'municipality',
value: string | undefined,
): SupporterListState => {
const params = buildSupporterListSearchParams(withPageReset(state))
const raw: Record<string, string | string[] | undefined> = Object.fromEntries(params.entries())
if (field === 'voteIntention') {
raw.voteIntention = value
} else if (field === 'city') {
raw.city = value
} else {
raw.municipality = value
}
return parseStateFromParams(raw)
}
export const applySupporterOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: SupporterListState
suggestionId: string
}): SupporterOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (suggestionId.startsWith('voteIntention:')) {
const value = suggestionId.slice(14)
const next =
state.voteIntention === value
? setExclusiveField(state, 'voteIntention', undefined)
: setExclusiveField(state, 'voteIntention', value)
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('city:')) {
const value = suggestionId.slice(5)
const next =
state.city === value
? setExclusiveField(state, 'city', undefined)
: setExclusiveField(state, 'city', value)
return { kind: 'url', state: next }
}
if (suggestionId.startsWith('municipality:')) {
const value = suggestionId.slice(13)
const next =
state.municipality === Number(value)
? setExclusiveField(state, 'municipality', undefined)
: setExclusiveField(state, 'municipality', value)
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
export const removeSupporterOmniboxChip = ({
state,
chipId,
}: {
state: SupporterListState
chipId: string
}): SupporterOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
if (chipId.startsWith('voteIntention:')) {
return { kind: 'url', state: setExclusiveField(state, 'voteIntention', undefined) }
}
if (chipId.startsWith('city:')) {
return { kind: 'url', state: setExclusiveField(state, 'city', undefined) }
}
if (chipId.startsWith('municipality:')) {
return { kind: 'url', state: setExclusiveField(state, 'municipality', undefined) }
}
return { kind: 'url', state }
}
export const clearSupporterOmnibox = (_state: SupporterListState): SupporterOmniboxAction => ({
kind: 'clear',
state: withPageReset({ page: 1 }),
})

View file

@ -0,0 +1,183 @@
/**
* Territory list omnibox adapter (B128). Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
clearTerritoryListFilters,
territoryCoverageLabels,
toggleTerritoryCoverageFilter,
toggleTerritoryRegionFilter,
type TerritoryFilterOption,
} from '@/utilities/territory/territoryListFilters'
import {
resolveTerritoryListSort,
territoryListSortOptions,
type TerritoryListSortDirection,
type TerritoryListSortKey,
type TerritoryListState,
} from '@/utilities/territory/territoryListUrl'
const DEFAULT_TERRITORY_LIST_SORT_KEY: TerritoryListSortKey = 'pct'
const DEFAULT_TERRITORY_LIST_SORT_DIR: TerritoryListSortDirection = 'desc'
export type TerritoryOmniboxAction =
| { kind: 'url'; state: TerritoryListState }
| { kind: 'clear'; state: TerritoryListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const isDefaultTerritoryListSort = (state: TerritoryListState): boolean => {
const { sort, dir } = resolveTerritoryListSort(state)
return sort === DEFAULT_TERRITORY_LIST_SORT_KEY && dir === DEFAULT_TERRITORY_LIST_SORT_DIR
}
export const buildTerritoryOmniboxChips = (state: TerritoryListState): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
for (const region of state.regions ?? []) {
chips.push({ id: `region:${region}`, label: chipLabel('Território', region) })
}
if (state.coverage) {
chips.push({
id: `coverage:${state.coverage}`,
label: chipLabel('Assessoria', territoryCoverageLabels[state.coverage]),
})
}
if (!isDefaultTerritoryListSort(state)) {
const { sort, dir } = resolveTerritoryListSort(state)
const option = territoryListSortOptions.find((entry) => entry.key === sort && entry.dir === dir)
chips.push({
id: 'sort',
label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`),
})
}
return chips
}
export const buildTerritoryOmniboxSuggestionSeeds = (
regionOptions: readonly TerritoryFilterOption[],
) => {
const seeds = []
for (const option of regionOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `region:${option.value}`,
group: 'Território',
label: option.label,
keywords: ['territorio', 'regiao'],
}),
)
}
for (const [value, label] of Object.entries(territoryCoverageLabels)) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `coverage:${value}`,
group: 'Assessoria',
label,
keywords: ['cobertura', 'assessor', 'assessoria'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of territoryListSortOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `sort:${option.key}|${option.dir}`,
group: 'Ordenação',
label: option.label,
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
}),
)
}
return seeds
}
export const filterTerritoryOmniboxSuggestions = (
seeds: ReturnType<typeof buildTerritoryOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyTerritoryOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: TerritoryListState
suggestionId: string
}): TerritoryOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: { ...state, q: q || undefined } }
}
if (suggestionId.startsWith('region:')) {
return {
kind: 'url',
state: toggleTerritoryRegionFilter(state, suggestionId.slice(7)),
}
}
if (suggestionId.startsWith('coverage:')) {
const value = suggestionId.slice(9)
if (value !== 'com_assessor' && value !== 'sem_assessor') return { kind: 'url', state }
return { kind: 'url', state: toggleTerritoryCoverageFilter(state, value) }
}
if (suggestionId.startsWith('sort:')) {
const raw = suggestionId.slice(5)
const [key, dir] = raw.split('|')
const option = territoryListSortOptions.find((entry) => entry.key === key && entry.dir === dir)
if (!option) return { kind: 'url', state }
const next = { ...state, sort: option.key, dir: option.dir }
if (isDefaultTerritoryListSort(next)) {
return { kind: 'url', state: { ...state, sort: undefined, dir: undefined } }
}
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
export const removeTerritoryOmniboxChip = ({
state,
chipId,
}: {
state: TerritoryListState
chipId: string
}): TerritoryOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: { ...state, q: undefined } }
if (chipId === 'sort') return { kind: 'url', state: { ...state, sort: undefined, dir: undefined } }
if (chipId.startsWith('region:')) {
return {
kind: 'url',
state: toggleTerritoryRegionFilter(state, chipId.slice(7)),
}
}
if (chipId.startsWith('coverage:')) {
return { kind: 'url', state: { ...state, coverage: undefined } }
}
return { kind: 'url', state }
}
export const clearTerritoryOmnibox = (state: TerritoryListState): TerritoryOmniboxAction => ({
kind: 'clear',
state: clearTerritoryListFilters(state),
})

View file

@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, createElement, type ReactElement } from 'react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const routerState = vi.hoisted(() => ({
replace: vi.fn(),
@ -11,119 +11,42 @@ vi.mock('next/navigation', async (importActual) => ({
useRouter: () => ({ replace: routerState.replace }),
}))
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'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
/**
* 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
searchLabel: string
/** Canonical href for a committed `silva` search with no other state. */
hrefWithQuery: string
/** Canonical href for no state at all — what "Limpar" must navigate to. */
bareHref: string
/**
* A `key|dir` from the list's own sort options that is NOT its default —
* otherwise `sort=` drops out of the canonical href and the assertion fails
* for a reason unrelated to the hook.
*/
sortValue: string
element: (currentQuery?: string) => ReactElement
/** Same shell with `sortValue` already committed — an external navigation. */
elementWithSort: () => ReactElement
}
type TestState = { q?: string; tag?: string }
const filterCases: FilterCase[] = [
{
name: 'dobradinhas',
searchLabel: 'Buscar dobradinha',
hrefWithQuery: '/campanha/dobradinhas?q=silva',
bareHref: '/campanha/dobradinhas',
sortValue: 'party|asc',
element: (currentQuery) =>
createElement(StateDeputyFilters, {
state: { page: 1, ...(currentQuery ? { q: currentQuery } : {}) },
partyOptions: [],
hasNoParty: false,
}),
elementWithSort: () =>
createElement(StateDeputyFilters, {
state: { page: 1, sort: 'party', dir: 'asc' },
partyOptions: [],
hasNoParty: false,
}),
},
{
name: 'territórios',
searchLabel: 'Buscar território',
hrefWithQuery: '/campanha/territorios?q=silva',
bareHref: '/campanha/territorios',
sortValue: 'region|asc',
element: (currentQuery) =>
createElement(TerritoryFilters, {
state: { ...(currentQuery ? { q: currentQuery } : {}) },
regionOptions: [],
}),
elementWithSort: () =>
createElement(TerritoryFilters, {
state: { sort: 'region', dir: 'asc' },
regionOptions: [],
}),
},
// 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.
]
/**
* Always inside the boundary: in production all three shells render under one,
* so the local `useTransition` fallback is the branch that never runs.
*/
const mountCase = ({ element }: FilterCase, currentQuery?: string) => {
const { rerender } = render(
createElement(CampaignListPendingBoundary, null, element(currentQuery)),
)
return {
/** Re-renders with new committed state, as a navigation from elsewhere would. */
rerenderWith: (next: ReactElement) =>
rerender(createElement(CampaignListPendingBoundary, null, next)),
}
}
const typeSearch = (label: string, value: string) => {
fireEvent.change(screen.getByLabelText(label), { target: { value } })
}
const searchInput = (label: string) => screen.getByLabelText(label) as HTMLInputElement
/**
* `dir` never appears in these: every `sortValue` here is an `asc` key, which is
* each list's default direction, so the canonical serializer drops it.
*/
const sortKeyOf = ({ sortValue }: FilterCase) => sortValue.split('|')[0]
const hrefWithSort = (filterCase: FilterCase) =>
`${filterCase.bareHref}?sort=${sortKeyOf(filterCase)}`
const hrefWithQueryAndSort = (filterCase: FilterCase) =>
`${filterCase.bareHref}?q=silva&sort=${sortKeyOf(filterCase)}`
const advance = (ms: number) => {
act(() => {
vi.advanceTimersByTime(ms)
const TestNavigateOnlyFilters = ({ state }: { state: TestState }) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => {
const params = new URLSearchParams()
if (next.q) params.set('q', next.q)
if (next.tag) params.set('tag', next.tag)
const query = params.toString()
return query ? `/test?${query}` : '/test'
},
})
return createElement(
'form',
{ role: 'search', 'data-pending': isPending },
createElement(
'button',
{
type: 'button',
onClick: () => navigate({ ...state, tag: 'applied' }),
},
'Aplicar',
),
)
}
const reactActEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
describe('campaign list filter navigation', () => {
describe('campaign list filter navigation (navigate-only / B128)', () => {
beforeAll(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
})
@ -132,174 +55,25 @@ describe('campaign list filter navigation', () => {
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
})
beforeEach(() => {
// React's scheduler needs real microtasks; only the debounce clock is faked.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
})
afterEach(() => {
cleanup()
vi.useRealTimers()
routerState.replace.mockReset()
})
it.each(filterCases)(
'$name: debounces the search into a single canonical replace',
(filterCase) => {
const { searchLabel, hrefWithQuery } = filterCase
mountCase(filterCase)
it('navigates immediately without debounce when using navigate()', () => {
render(
createElement(
CampaignListPendingBoundary,
null,
createElement(TestNavigateOnlyFilters, { state: {} }),
),
)
typeSearch(searchLabel, 'silva')
advance(SEARCH_DEBOUNCE_MS - 1)
expect(routerState.replace).not.toHaveBeenCalled()
act(() => {
fireEvent.click(screen.getByRole('button', { name: 'Aplicar' }))
})
advance(1)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQuery, { scroll: false })
},
)
it.each(filterCases)(
'$name: restarts the debounce window while the user keeps typing',
(filterCase) => {
const { searchLabel, hrefWithQuery } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'sil')
advance(SEARCH_DEBOUNCE_MS - 200)
typeSearch(searchLabel, 'silva')
advance(SEARCH_DEBOUNCE_MS - 200)
expect(routerState.replace).not.toHaveBeenCalled()
advance(200)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQuery, { scroll: false })
},
)
it.each(filterCases)(
'$name: skips navigation when the query would not change the URL',
(filterCase) => {
const { searchLabel } = filterCase
mountCase(filterCase, 'silva')
typeSearch(searchLabel, 'silva ')
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).not.toHaveBeenCalled()
fireEvent.submit(screen.getByRole('search'))
expect(routerState.replace).not.toHaveBeenCalled()
},
)
it.each(filterCases)('$name: cancels the pending search navigation on unmount', (filterCase) => {
const { searchLabel } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'silva')
cleanup()
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).not.toHaveBeenCalled()
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith('/test?tag=applied', { scroll: false })
})
it.each(filterCases)(
'$name: carries the uncommitted search when another control navigates',
(filterCase) => {
const { searchLabel, sortValue } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'silva')
fireEvent.change(screen.getByLabelText('Ordenar'), { target: { value: sortValue } })
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQueryAndSort(filterCase), {
scroll: false,
})
// The sort consumed the pending search; the timer must not fire a second one.
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).toHaveBeenCalledTimes(1)
},
)
it.each(filterCases)(
'$name: clears the box and the query in one deduped navigation',
(filterCase) => {
const { searchLabel, bareHref } = filterCase
mountCase(filterCase, 'silva')
fireEvent.click(screen.getByRole('button', { name: 'Limpar' }))
expect((screen.getByLabelText(searchLabel) as HTMLInputElement).value).toBe('')
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(bareHref, { scroll: false })
},
)
it.each(filterCases)(
'$name: a pending search does not revert a navigation made from outside the shell',
(filterCase) => {
const { searchLabel, elementWithSort } = filterCase
const { rerenderWith } = mountCase(filterCase)
typeSearch(searchLabel, 'silva')
// A sortable head / pagination link commits new state mid-debounce.
rerenderWith(elementWithSort())
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(hrefWithQueryAndSort(filterCase), {
scroll: false,
})
},
)
it.each(filterCases)(
'$name: follows the URL when the committed query is dropped from outside the shell',
(filterCase) => {
const { searchLabel, element } = filterCase
const { rerenderWith } = mountCase(filterCase, 'silva')
// The empty state's "Limpar busca e filtros" is an anchor: it drops `q`
// from the URL without this shell's controls being touched.
rerenderWith(element())
expect(searchInput(searchLabel).value).toBe('')
// The box must not put the dropped query back on the next filter touch.
fireEvent.change(screen.getByLabelText('Ordenar'), {
target: { value: filterCase.sortValue },
})
expect(routerState.replace).toHaveBeenCalledWith(hrefWithSort(filterCase), { scroll: false })
},
)
it.each(filterCases)(
'$name: the active-filters summary describes the typed query before it commits',
(filterCase) => {
const { searchLabel } = filterCase
mountCase(filterCase)
typeSearch(searchLabel, 'silva')
// One copy per breakpoint: dobradinhas and territórios also render the
// summary in their mobile column, municípios only in the desktop row.
expect(screen.getAllByText(/Busca "silva"/).length).toBeGreaterThan(0)
expect(routerState.replace).not.toHaveBeenCalled()
},
)
it.each(filterCases)(
'$name: emptying a committed search navigates to the query-less URL',
(filterCase) => {
const { searchLabel, bareHref } = filterCase
mountCase(filterCase, 'silva')
typeSearch(searchLabel, '')
advance(SEARCH_DEBOUNCE_MS)
expect(routerState.replace).toHaveBeenCalledTimes(1)
expect(routerState.replace).toHaveBeenCalledWith(bareHref, { scroll: false })
},
)
})

View file

@ -375,6 +375,7 @@ describe('src/utilities top-level is pinned', () => {
'activityDetailPageData.ts',
'activityDetailTabUi.ts',
'activityFormData.ts',
'activityOmnibox.ts',
'activityLeadershipOptions.ts',
'activityPageData.ts',
'activityRelationOptions.ts',
@ -441,6 +442,7 @@ describe('src/utilities top-level is pinned', () => {
'stateDeputyData.ts',
'stateDeputyListFilters.ts',
'stateDeputyListUrl.ts',
'stateDeputyOmnibox.ts',
'voteEstimateScenarioFields.ts',
'votePledgeData.ts',
'votePledgeViews.ts',

View file

@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import { parseActivityListParams } from '@/utilities/activityUi'
import {
applyActivityOmniboxSuggestion,
buildActivityOmniboxChips,
clearActivityOmnibox,
} from '@/utilities/activityOmnibox'
import { parseDemandListParams } from '@/utilities/demand/demandListUrl'
import {
applyDemandOmniboxSuggestion,
buildDemandOmniboxChips,
clearDemandOmnibox,
} from '@/utilities/demand/demandOmnibox'
import { parseLeadershipListParams } from '@/utilities/leadership/leadershipListUrl'
import {
applyLeadershipOmniboxSuggestion,
buildLeadershipOmniboxSuggestionSeeds,
filterLeadershipOmniboxSuggestions,
} from '@/utilities/leadership/leadershipOmnibox'
import { parseStateDeputyListParams } from '@/utilities/stateDeputyListUrl'
import {
applyStateDeputyOmniboxSuggestion,
buildStateDeputyOmniboxChips,
} from '@/utilities/stateDeputyOmnibox'
import { parseSupporterListParams } from '@/utilities/supporter/supporterUi'
import {
applySupporterOmniboxSuggestion,
buildSupporterOmniboxChips,
} from '@/utilities/supporter/supporterOmnibox'
import { parseTerritoryListParams } from '@/utilities/territory/territoryListUrl'
import {
applyTerritoryOmniboxSuggestion,
buildTerritoryOmniboxChips,
buildTerritoryOmniboxSuggestionSeeds,
filterTerritoryOmniboxSuggestions,
} from '@/utilities/territory/territoryOmnibox'
import {
applySearchOnlyOmniboxSuggestion,
buildSearchOnlyOmniboxChips,
} from '@/lib/searchOnlyListOmnibox'
describe('list omnibox adapters (B128)', () => {
it('territory builds chips and applies search', () => {
const state = parseTerritoryListParams({ q: 'rec', region: 'Recôncavo' })
const chips = buildTerritoryOmniboxChips(state)
expect(chips.some((chip) => chip.id === 'q')).toBe(true)
expect(chips.some((chip) => chip.id === 'region:Recôncavo')).toBe(true)
const applied = applyTerritoryOmniboxSuggestion({ state, suggestionId: 'q:Salvador' })
expect(applied.kind).toBe('url')
if (applied.kind === 'url') expect(applied.state.q).toBe('Salvador')
})
it('territory suggests sort when typing ordenar', () => {
const seeds = buildTerritoryOmniboxSuggestionSeeds([])
const suggestions = filterTerritoryOmniboxSuggestions(seeds, 'ordenar')
expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true)
})
it('leadership applies status toggle and builds chips', () => {
const base = parseLeadershipListParams({})
const applied = applyLeadershipOmniboxSuggestion({
state: base,
suggestionId: 'status:engajado',
})
expect(applied.kind).toBe('url')
if (applied.kind === 'url') {
expect(applied.state.statuses).toContain('engajado')
}
const seeds = buildLeadershipOmniboxSuggestionSeeds({ municipalityFilterOptions: [] })
const suggestions = filterLeadershipOmniboxSuggestions(seeds, 'ordenar')
expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true)
})
it('state deputy toggles party filter', () => {
const base = parseStateDeputyListParams({})
const applied = applyStateDeputyOmniboxSuggestion({
state: base,
suggestionId: 'party:PT',
})
expect(applied.kind).toBe('url')
if (applied.kind === 'url') expect(applied.state.parties).toEqual(['PT'])
const chips = buildStateDeputyOmniboxChips(
parseStateDeputyListParams({ q: 'joão', party: 'PT' }),
)
expect(chips.map((chip) => chip.id)).toEqual(['q', 'party:PT'])
})
it('supporter applies vote intention exclusively', () => {
const base = parseSupporterListParams({})
const applied = applySupporterOmniboxSuggestion({
state: base,
suggestionId: 'voteIntention:certo',
})
expect(applied.kind).toBe('url')
if (applied.kind === 'url') expect(applied.state.voteIntention).toBe('certo')
const chips = buildSupporterOmniboxChips({
state: parseSupporterListParams({ voteIntention: 'indeciso' }),
municipalityLabelsById: new Map(),
})
expect(chips[0]?.id).toBe('voteIntention:indeciso')
})
it('activity clears filters but keeps tab', () => {
const state = parseActivityListParams({ tab: 'todos', kind: 'comicio', status: 'planejado' })
const chips = buildActivityOmniboxChips({ state, municipalityLabelsById: new Map() })
expect(chips.length).toBeGreaterThan(0)
const cleared = clearActivityOmnibox(state)
expect(cleared.kind).toBe('clear')
if (cleared.kind === 'clear') {
expect(cleared.state.tab).toBe('todos')
expect(cleared.state.kind).toBeUndefined()
}
const applied = applyActivityOmniboxSuggestion({ state, suggestionId: 'kind:comicio' })
expect(applied.kind).toBe('url')
})
it('demand toggles exclusive status and preserves deep-link fields on clear', () => {
const state = parseDemandListParams({ status: 'aberta', activity: '42' })
const chips = buildDemandOmniboxChips(state)
expect(chips[0]?.label).toContain('Aberta')
const toggled = applyDemandOmniboxSuggestion({ state, suggestionId: 'status:aberta' })
expect(toggled.kind).toBe('url')
if (toggled.kind === 'url') expect(toggled.state.status).toBeUndefined()
const cleared = clearDemandOmnibox(state)
if (cleared.kind === 'clear') expect(cleared.state.activityId).toBe(42)
})
it('search-only omnibox commits q', () => {
const chips = buildSearchOnlyOmniboxChips({ q: 'sindicato' })
expect(chips[0]?.label).toBe('Busca: sindicato')
const applied = applySearchOnlyOmniboxSuggestion({
state: { page: 2 },
suggestionId: 'q:ana',
withPageReset: (next) => ({ ...next, page: 1 }),
})
expect(applied).toEqual({ kind: 'url', state: { page: 1, q: 'ana' } })
})
})