Merge pull request #282 from fsolla/cursor/pool-i265-cbfa8dec-92a1
B128 — Adotar barra omnibox nas demais listas /campanha
This commit is contained in:
commit
1771c5c2c4
38 changed files with 2327 additions and 1410 deletions
59
docs/plans/adotar-barra-filtros-omnibox-listas-impl.md
Normal file
59
docs/plans/adotar-barra-filtros-omnibox-listas-impl.md
Normal 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
|
||||
|
|
@ -46,6 +46,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
`${CAMPAIGN_APP}/atividades`,
|
||||
'src/components/campaign/activity',
|
||||
'src/utilities/activity',
|
||||
'src/utilities/activityOmnibox',
|
||||
'src/lib/activityQuickActions',
|
||||
],
|
||||
specs: ['campaignActivity'],
|
||||
|
|
@ -113,6 +114,15 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/components/campaign/supporter',
|
||||
'src/components/campaign/votePledge',
|
||||
'src/components/campaign/auth',
|
||||
'src/lib/searchOnlyListOmnibox',
|
||||
'src/utilities/advisor',
|
||||
'src/utilities/advisorData',
|
||||
'src/utilities/campaignDemandData',
|
||||
'src/utilities/demand',
|
||||
'src/utilities/organization',
|
||||
'src/utilities/organizationData',
|
||||
'src/utilities/stateDeputyOmnibox',
|
||||
'src/utilities/supporter/supporterOmnibox',
|
||||
`${CAMPAIGN_APP}/acoes`,
|
||||
'src/components/campaign/shared/WizardMunicipality',
|
||||
'src/components/campaign/shared/useCampaignListFilterNavigation',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,40 +1,30 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { CampaignCollapsibleFilterPanel } from '@/components/campaign/shared/CampaignCollapsibleFilterPanel'
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useCampaignFilterValues } from '@/components/campaign/shared/useCampaignFilterValues'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
87
src/components/campaign/advisor/AdvisorFilters.tsx
Normal file
87
src/components/campaign/advisor/AdvisorFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/components/campaign/demand/DemandFilters.tsx
Normal file
72
src/components/campaign/demand/DemandFilters.tsx
Normal 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 { buildDemandListHref } from '@/utilities/demand/demandListUrl'
|
||||
import {
|
||||
applyDemandOmniboxSuggestion,
|
||||
buildDemandOmniboxChips,
|
||||
buildDemandOmniboxSuggestionSeeds,
|
||||
clearDemandOmnibox,
|
||||
filterDemandOmniboxSuggestions,
|
||||
removeDemandOmniboxChip,
|
||||
type DemandListState,
|
||||
type DemandOmniboxAction,
|
||||
} from '@/utilities/demand/demandOmnibox'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
clearTerritoryListFilters,
|
||||
formatTerritoryActiveFiltersSummary,
|
||||
territoryCoverageLabels,
|
||||
toggleTerritoryRegionFilter,
|
||||
type TerritoryFilterOption,
|
||||
} from '@/utilities/territory/territoryListFilters'
|
||||
import type { TerritoryFilterOption } from '@/utilities/territory/territoryListFilters'
|
||||
import {
|
||||
buildTerritoryListHref,
|
||||
parseTerritorySortValue,
|
||||
resolveTerritoryListSort,
|
||||
serializeTerritorySortValue,
|
||||
territoryListSortOptions,
|
||||
type TerritoryListState,
|
||||
} from '@/utilities/territory/territoryListUrl'
|
||||
import {
|
||||
applyTerritoryOmniboxSuggestion,
|
||||
buildTerritoryOmniboxChips,
|
||||
buildTerritoryOmniboxSuggestionSeeds,
|
||||
clearTerritoryOmnibox,
|
||||
filterTerritoryOmniboxSuggestions,
|
||||
removeTerritoryOmniboxChip,
|
||||
type TerritoryOmniboxAction,
|
||||
} from '@/utilities/territory/territoryOmnibox'
|
||||
|
||||
export const TerritoryFilters = ({
|
||||
state,
|
||||
|
|
@ -29,105 +26,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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
93
src/components/campaign/organization/OrganizationFilters.tsx
Normal file
93
src/components/campaign/organization/OrganizationFilters.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* The collapsible filter panel of the list filter shells (P3-F — extracted
|
||||
* from ActivityFilters/SupporterFilters, ~30 near-verbatim lines each):
|
||||
* always-open on desktop, a disclosure card with 44 px targets on mobile,
|
||||
* and the "Limpar filtros" footer when any filter is active.
|
||||
*/
|
||||
export const CampaignCollapsibleFilterPanel = ({
|
||||
panelId,
|
||||
hasFilters,
|
||||
onClear,
|
||||
children,
|
||||
}: {
|
||||
panelId: string
|
||||
hasFilters: boolean
|
||||
onClear: () => void
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="rounded-[6px] border bg-card">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 w-full justify-start rounded-[6px] px-3 lg:hidden"
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span>Filtros</span>
|
||||
<ChevronDownIcon data-icon="inline-end" className="ml-auto" aria-hidden="true" />
|
||||
</Button>
|
||||
<div
|
||||
id={panelId}
|
||||
className={cn(
|
||||
mobileFiltersOpen ? 'block' : 'hidden',
|
||||
'border-t p-4 lg:block lg:border-t-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{hasFilters ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 rounded-[6px]"
|
||||
onClick={onClear}
|
||||
>
|
||||
<XIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type MouseEvent } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type CampaignFilterChip = {
|
||||
href: string
|
||||
label: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Status/kind chips that navigate via URL. Optimistic on the chip (the tapped
|
||||
* one activates at once), honest pending on the results (shared boundary
|
||||
* transition dims them) — "Feel the action".
|
||||
*/
|
||||
export const CampaignFilterChips = ({
|
||||
ariaLabel,
|
||||
chips,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
chips: CampaignFilterChip[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const [optimisticHref, setOptimisticHref] = useState<string | null>(null)
|
||||
|
||||
const activeHref =
|
||||
isPending && optimisticHref !== null
|
||||
? optimisticHref
|
||||
: (chips.find((chip) => chip.active)?.href ?? null)
|
||||
|
||||
const interceptNavigation = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setOptimisticHref(href)
|
||||
startTransition(() => {
|
||||
router.push(href)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={ariaLabel} aria-busy={isPending} className="flex flex-wrap gap-2">
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.href}
|
||||
asChild
|
||||
variant={chip.href === activeHref ? 'default' : 'outline'}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link href={chip.href} onClick={(event) => interceptNavigation(event, chip.href)}>
|
||||
{chip.label}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
|
||||
|
||||
type CampaignMobileMultiFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CampaignMobileMultiFilterField = ({
|
||||
id,
|
||||
label,
|
||||
emptyLabel,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
emptyLabel: string
|
||||
options: CampaignMobileMultiFilterOption[]
|
||||
selected: readonly string[]
|
||||
onToggle: (value: string) => void
|
||||
}) => {
|
||||
// No snapshot: the NativeSelect closes on every choice, so the next open
|
||||
// already wants the just-toggled value at the top.
|
||||
const { ordered } = orderFilterOptionsSelectedFirst(options, selected)
|
||||
const selectedSet = new Set(selected)
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onToggle(event.target.value)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
|
||||
</NativeSelectOption>
|
||||
{ordered.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{selectedSet.has(option.value) ? `✓ ${option.label}` : option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* Name-search form for simple list pages (lideranças, organizações,
|
||||
* dobradinhas): keeps the input value locally (optimistic) and navigates in a
|
||||
* transition so the results region dims instead of a dead full-page wait.
|
||||
*/
|
||||
export const CampaignSearchForm = ({
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
initialQuery,
|
||||
basePath,
|
||||
filterParams = '',
|
||||
}: {
|
||||
ariaLabel: string
|
||||
placeholder: string
|
||||
initialQuery: string
|
||||
/** List route; the query lands as `?q=` (serializable — no function props across the RSC boundary). */
|
||||
basePath: string
|
||||
/**
|
||||
* The ACTIVE filters' canonical serialization (minus `q`/`page`), produced
|
||||
* server-side by the domain's own serializer (P3-F): submitting the search
|
||||
* must not silently drop the other filters, which the hand-rolled `?q=`
|
||||
* used to do.
|
||||
*/
|
||||
filterParams?: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
|
||||
const hrefForQuery = (value: string) => {
|
||||
const params = new URLSearchParams(filterParams)
|
||||
if (value) params.set('q', value)
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${basePath}?${queryString}` : basePath
|
||||
}
|
||||
|
||||
const navigateIfChanged = (value: string) => {
|
||||
// No-op guard (same policy as `useCampaignListFilterNavigation`): the URL
|
||||
// already carries exactly this query — don't pay an RSC round-trip.
|
||||
if (value === initialQuery) return
|
||||
startTransition(() => {
|
||||
router.replace(hrefForQuery(value), { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-as-you-type (P3-F unified idiom — this form used to navigate only
|
||||
* on explicit submit): the debounce commits what the user settled on; the
|
||||
* Buscar button remains as the immediate path for keyboard users in a hurry.
|
||||
*/
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
const onQueryChange = (value: string) => {
|
||||
setQuery(value)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
navigateIfChanged(value)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className="flex gap-2 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending || undefined}
|
||||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
navigateIfChanged(query)
|
||||
}}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="min-h-11" disabled={isPending}>
|
||||
Buscar
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
|
||||
/**
|
||||
* The filter-values state machine of the list filter shells (P3-F — extracted
|
||||
* from ActivityFilters/SupporterFilters, where it was spelled twice WITHOUT
|
||||
* the no-op guard): local mirror for instant feedback, committed-href
|
||||
* deduplication so re-selecting the already-active value never pays an RSC
|
||||
* round-trip, and the pending transition the results region dims with.
|
||||
*
|
||||
* `toHref` comes from the domain's own serializer — the hook never decides
|
||||
* what is canonical.
|
||||
*/
|
||||
export const useCampaignFilterValues = <Values>({
|
||||
committedValues,
|
||||
toHref,
|
||||
}: {
|
||||
/** Values parsed from the URL on the server — fresh on every render. */
|
||||
committedValues: Values
|
||||
/** Domain serializer; must canonicalize and pin the page reset it wants. */
|
||||
toHref: (values: Values) => string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const valuesRef = useRef(committedValues)
|
||||
const [values, setValues] = useState(committedValues)
|
||||
|
||||
// The committed URL's href, written in an effect: an interrupted transition
|
||||
// render is abandoned before commit, and a later navigation must not be
|
||||
// deduplicated against state that never landed (same lesson as
|
||||
// `useCampaignListFilterNavigation`'s stateRef).
|
||||
const committedHrefRef = useRef(toHref(committedValues))
|
||||
useEffect(() => {
|
||||
committedHrefRef.current = toHref(committedValues)
|
||||
})
|
||||
|
||||
const replaceValues = (next: Values) => {
|
||||
valuesRef.current = next
|
||||
setValues(next)
|
||||
const href = toHref(next)
|
||||
if (href === committedHrefRef.current) return
|
||||
startTransition(() => {
|
||||
router.replace(href, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
/** Functional update against the LATEST values, not the render that scheduled it. */
|
||||
const updateValues = (updater: (current: Values) => Values) => {
|
||||
replaceValues(updater(valuesRef.current))
|
||||
}
|
||||
|
||||
/** Mirror a keystroke locally WITHOUT navigating (the debounce commits later). */
|
||||
const setLocalValues = (updater: (current: Values) => Values) => {
|
||||
const next = updater(valuesRef.current)
|
||||
valuesRef.current = next
|
||||
setValues(next)
|
||||
}
|
||||
|
||||
return { values, isPending, replaceValues, updateValues, setLocalValues }
|
||||
}
|
||||
|
|
@ -1,29 +1,16 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { normalizedText } from '@/utilities/campaignListUrl'
|
||||
|
||||
/**
|
||||
* Long for search-as-you-type on purpose: every commit is an RSC round trip
|
||||
* over a mobile connection, and the field team types município names slowly.
|
||||
* Navigation scaffold for list filter shells (B127/B128 omnibox). The hook never
|
||||
* decides what is canonical — `toHref` comes from the domain's own URL module
|
||||
* (`buildXListHref`, which already canonicalizes).
|
||||
*/
|
||||
export const SEARCH_DEBOUNCE_MS = 1000
|
||||
|
||||
/**
|
||||
* Navigation scaffold shared by the list filter shells that still use a
|
||||
* debounced search box (territórios, dobradinhas). Municípios moved to the
|
||||
* omnibox in B127 but still calls `navigateWithSearch` from the same hook.
|
||||
*
|
||||
* The hook never decides what is canonical — `toHref` comes from the domain's
|
||||
* own URL module (`buildXListHref`, which already canonicalizes), so the three
|
||||
* URL contracts stay independent instead of collapsing into one DSL. Raw
|
||||
* 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,
|
||||
}: {
|
||||
|
|
@ -34,51 +21,13 @@ 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 ?? '')
|
||||
|
||||
/**
|
||||
* Follow the URL when the committed query changes under us — back/forward and
|
||||
* the empty state's "Limpar busca e filtros" navigate from outside this shell.
|
||||
* Without this the box would keep showing a query the URL no longer has, and
|
||||
* 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 ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The controls that navigate from *outside* this shell — sortable heads,
|
||||
* pagination — change `state` without touching the pending timer. Reading the
|
||||
* committed state through a ref keeps a timer scheduled before such a
|
||||
* navigation from reverting it a second later. Written in an effect, not
|
||||
* during render: an interrupted transition render is abandoned before commit,
|
||||
* and the timer must not resolve against state that never landed.
|
||||
*/
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
})
|
||||
|
||||
const clearDebounce = () => {
|
||||
if (!debounceRef.current) return
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
|
||||
useEffect(() => clearDebounce, [])
|
||||
|
||||
/**
|
||||
* Cancels any pending search, then navigates — unless `next` serializes to
|
||||
* what is already committed. Both sides pin the same page, so the guard asks
|
||||
* "do the filters and sort change?", not "is this the current URL?": a
|
||||
* re-submitted identical search on page 3 leaves the user on page 3.
|
||||
*/
|
||||
const navigateTo = (next: State) => {
|
||||
clearDebounce()
|
||||
const navigate = (next: State) => {
|
||||
const nextHref = toHref(next)
|
||||
if (nextHref === toHref(stateRef.current)) return
|
||||
startTransition(() => {
|
||||
|
|
@ -86,58 +35,8 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates carrying the search the user has typed but not yet committed —
|
||||
* otherwise touching a filter mid-typing would drop it while the input keeps
|
||||
* showing the text.
|
||||
*/
|
||||
const navigateWithSearch = (next: State) => {
|
||||
navigateTo({ ...next, q: search })
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Limpar" buttons: empties the box and drops `q` in the same gesture.
|
||||
* Exists so no caller has to know that `navigateWithSearch` would re-commit
|
||||
* the search being cleared, because `setSearch('')` has not flushed yet.
|
||||
*/
|
||||
const clearSearchAndNavigate = (next: State) => {
|
||||
setSearch('')
|
||||
navigateTo(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* The search input's only handler: mirrors the keystroke and (re)arms the
|
||||
* debounce in one call, so no caller can wire up half of it.
|
||||
*
|
||||
* Typing back to the committed value still arms a timer — it cancels the
|
||||
* pending navigation rather than merely skipping a new one, because the guard
|
||||
* lives in `navigateTo` and the timer is free to resolve to a no-op.
|
||||
*/
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearch(value)
|
||||
clearDebounce()
|
||||
debounceRef.current = setTimeout(() => {
|
||||
navigateTo({ ...stateRef.current, q: value })
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
onSearchChange,
|
||||
/**
|
||||
* What the active-filters summary should describe. The draft wins while the
|
||||
* box is non-empty; an emptied box falls back to the committed `q` so the
|
||||
* summary does not flicker off during the debounce, before the results it
|
||||
* describes have actually changed.
|
||||
*/
|
||||
draftQ: normalizedText(search) || state.q,
|
||||
isPending,
|
||||
/**
|
||||
* Navigate with the caller's state as-is (no draft-search merge). Used by
|
||||
* shells that own committed `q` themselves (B127 omnibox).
|
||||
*/
|
||||
navigate: navigateTo,
|
||||
navigateWithSearch,
|
||||
clearSearchAndNavigate,
|
||||
navigate,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,22 @@
|
|||
'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 { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
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 { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
buildSupporterListSearchParams,
|
||||
supporterVoteIntentionLabels,
|
||||
type SupporterListState,
|
||||
} from '@/utilities/supporter/supporterUi'
|
||||
applySupporterOmniboxSuggestion,
|
||||
buildSupporterOmniboxChips,
|
||||
buildSupporterOmniboxSuggestionSeeds,
|
||||
clearSupporterOmnibox,
|
||||
filterSupporterOmniboxSuggestions,
|
||||
removeSupporterOmniboxChip,
|
||||
type SupporterOmniboxAction,
|
||||
} from '@/utilities/supporter/supporterOmnibox'
|
||||
import { buildSupporterListHref, type SupporterListState } from '@/utilities/supporter/supporterUi'
|
||||
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
|
||||
|
||||
export const SupporterFilters = ({
|
||||
state,
|
||||
municipalityOptions,
|
||||
|
|
@ -40,126 +24,92 @@ 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,68 @@ const omniboxQueryMatches = (
|
|||
|
||||
export const omniboxGroupMatches = (group: string, needle: string): boolean =>
|
||||
omniboxQueryMatches(group, needle)
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
68
src/lib/searchOnlyListOmnibox.ts
Normal file
68
src/lib/searchOnlyListOmnibox.ts
Normal 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,
|
||||
})
|
||||
211
src/utilities/activityOmnibox.ts
Normal file
211
src/utilities/activityOmnibox.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Activity list omnibox adapter (B128). Pure / client-safe.
|
||||
* Tab preset stays outside the omnibox (product map).
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity'
|
||||
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 }),
|
||||
})
|
||||
33
src/utilities/advisor/advisorListUrl.ts
Normal file
33
src/utilities/advisor/advisorListUrl.ts
Normal 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)
|
||||
|
|
@ -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 { advisorListHrefForPage, parseAdvisorListParams }
|
||||
export type { AdvisorListState }
|
||||
|
||||
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>(
|
||||
|
|
|
|||
|
|
@ -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 { buildDemandListHref, parseDemandListParams }
|
||||
export type { DemandListState }
|
||||
|
||||
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[],
|
||||
|
|
|
|||
47
src/utilities/demand/demandListUrl.ts
Normal file
47
src/utilities/demand/demandListUrl.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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)
|
||||
100
src/utilities/demand/demandOmnibox.ts
Normal file
100
src/utilities/demand/demandOmnibox.ts
Normal 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 {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import {
|
||||
campaignDemandStatusLabels,
|
||||
campaignDemandStatuses,
|
||||
type CampaignDemandStatus,
|
||||
} from '@/lib/schemas/campaignDemand'
|
||||
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 } : {}),
|
||||
}),
|
||||
})
|
||||
265
src/utilities/leadership/leadershipOmnibox.ts
Normal file
265
src/utilities/leadership/leadershipOmnibox.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* Leadership list omnibox adapter (B128). Pure / client-safe.
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import {
|
||||
leadershipAccessFilterLabels,
|
||||
supportStatusLabels,
|
||||
} from '@/utilities/leadership/leadershipLabels'
|
||||
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'
|
||||
|
||||
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),
|
||||
})
|
||||
44
src/utilities/organization/organizationListUrl.ts
Normal file
44
src/utilities/organization/organizationListUrl.ts
Normal 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)
|
||||
|
|
@ -4,17 +4,17 @@ 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'
|
||||
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
|
||||
import {
|
||||
buildOrganizationListHref,
|
||||
parseOrganizationListParams,
|
||||
type OrganizationListState,
|
||||
} from '@/utilities/organization/organizationListUrl'
|
||||
|
||||
export { buildOrganizationListHref, parseOrganizationListParams }
|
||||
export type { OrganizationListState }
|
||||
|
||||
const organizationPageSize = 25
|
||||
|
||||
|
|
@ -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[],
|
||||
|
|
|
|||
177
src/utilities/stateDeputyOmnibox.ts
Normal file
177
src/utilities/stateDeputyOmnibox.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* 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),
|
||||
})
|
||||
210
src/utilities/supporter/supporterOmnibox.ts
Normal file
210
src/utilities/supporter/supporterOmnibox.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* 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 }),
|
||||
})
|
||||
|
|
@ -165,17 +165,3 @@ const sortOptionLabel = (key: TerritoryListSortKey, dir: TerritoryListSortDirect
|
|||
export const territoryListSortOptions = territoryListSortKeys.flatMap((key) =>
|
||||
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: sortOptionLabel(key, dir) })),
|
||||
)
|
||||
|
||||
export const serializeTerritorySortValue = (
|
||||
key: TerritoryListSortKey,
|
||||
dir: TerritoryListSortDirection,
|
||||
): string => `${key}|${dir}`
|
||||
|
||||
export const parseTerritorySortValue = (
|
||||
value: string,
|
||||
): { key: TerritoryListSortKey; dir: TerritoryListSortDirection } | null => {
|
||||
const [key, dir] = value.split('|')
|
||||
if (!territoryListSortKeySet.has(key)) return null
|
||||
if (!territoryListSortDirSet.has(dir as TerritoryListSortDirection)) return null
|
||||
return { key: key as TerritoryListSortKey, dir: dir as TerritoryListSortDirection }
|
||||
}
|
||||
|
|
|
|||
186
src/utilities/territory/territoryOmnibox.ts
Normal file
186
src/utilities/territory/territoryOmnibox.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* 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),
|
||||
})
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
148
tests/unit/listOmniboxB128.unit.spec.ts
Normal file
148
tests/unit/listOmniboxB128.unit.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
applySearchOnlyOmniboxSuggestion,
|
||||
buildSearchOnlyOmniboxChips,
|
||||
} from '@/lib/searchOnlyListOmnibox'
|
||||
import {
|
||||
applyActivityOmniboxSuggestion,
|
||||
buildActivityOmniboxChips,
|
||||
clearActivityOmnibox,
|
||||
} from '@/utilities/activityOmnibox'
|
||||
import { parseActivityListParams } from '@/utilities/activityUi'
|
||||
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 {
|
||||
applySupporterOmniboxSuggestion,
|
||||
buildSupporterOmniboxChips,
|
||||
} from '@/utilities/supporter/supporterOmnibox'
|
||||
import { parseSupporterListParams } from '@/utilities/supporter/supporterUi'
|
||||
import { parseTerritoryListParams } from '@/utilities/territory/territoryListUrl'
|
||||
import {
|
||||
applyTerritoryOmniboxSuggestion,
|
||||
buildTerritoryOmniboxChips,
|
||||
buildTerritoryOmniboxSuggestionSeeds,
|
||||
filterTerritoryOmniboxSuggestions,
|
||||
} from '@/utilities/territory/territoryOmnibox'
|
||||
|
||||
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' } })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue