chore: remove orphaned list filter shells after B128 omnibox migration

Delete CampaignSearchForm, CampaignFilterChips, collapsible/mobile filter
components and useCampaignFilterValues. Slim useCampaignListFilterNavigation
to navigate-only; drop unused territory sort serializers.
This commit is contained in:
Cursor Agent 2026-08-02 20:57:47 +00:00
parent f59d4a6736
commit cf78c61757
No known key found for this signature in database
8 changed files with 7 additions and 493 deletions

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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 }
}

View file

@ -1,35 +1,14 @@
'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.
*/
export const SEARCH_DEBOUNCE_MS = 1000
type StateWithOptionalQ = { q?: string }
const readCommittedQ = (state: object): string | undefined =>
'q' in state ? (state as StateWithOptionalQ).q : undefined
const withDraftQ = <State extends object>(state: State, q: string): State =>
({ ...state, q }) as State
/**
* Navigation scaffold shared by the list filter shells that still use a
* debounced search box (territórios, dobradinhas). Municípios moved to the
* 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.
* 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 useCampaignListFilterNavigation = <State extends object>({
state,
@ -42,52 +21,13 @@ export const useCampaignListFilterNavigation = <State extends object>({
}) => {
const router = useRouter()
const { isPending, startTransition } = useCampaignListTransition()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [search, setSearch] = useState(readCommittedQ(state) ?? '')
/**
* 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(readCommittedQ(state))
const stateQ = readCommittedQ(state)
if (stateQ !== committedQ) {
setCommittedQ(stateQ)
if (!debounceRef.current) setSearch(stateQ ?? '')
}
/**
* 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(() => {
@ -95,58 +35,8 @@ export const useCampaignListFilterNavigation = <State extends object>({
})
}
/**
* 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(withDraftQ(next, 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(withDraftQ(stateRef.current, 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) || readCommittedQ(state),
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,
}
}

View file

@ -32,7 +32,7 @@ const omniboxQueryMatches = (
export const omniboxGroupMatches = (group: string, needle: string): boolean =>
omniboxQueryMatches(group, needle)
export const SUGGESTION_CAP_PER_GROUP = 8
const SUGGESTION_CAP_PER_GROUP = 8
export type OmniboxSuggestionSeed = CampaignListOmniboxSuggestion & {
/** When true, show even with an empty query (dimension shortcuts). */

View file

@ -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 }
}