From cf78c61757dd1224a6da15500208b4917b39bc39 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 20:57:47 +0000 Subject: [PATCH] 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. --- .../shared/CampaignCollapsibleFilterPanel.tsx | 66 ---------- .../campaign/shared/CampaignFilterChips.tsx | 74 ----------- .../shared/CampaignMobileMultiFilterField.tsx | 54 -------- .../campaign/shared/CampaignSearchForm.tsx | 104 --------------- .../shared/useCampaignFilterValues.ts | 64 --------- .../shared/useCampaignListFilterNavigation.ts | 122 +----------------- src/lib/campaignListOmnibox.ts | 2 +- src/utilities/territory/territoryListUrl.ts | 14 -- 8 files changed, 7 insertions(+), 493 deletions(-) delete mode 100644 src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx delete mode 100644 src/components/campaign/shared/CampaignFilterChips.tsx delete mode 100644 src/components/campaign/shared/CampaignMobileMultiFilterField.tsx delete mode 100644 src/components/campaign/shared/CampaignSearchForm.tsx delete mode 100644 src/components/campaign/shared/useCampaignFilterValues.ts diff --git a/src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx b/src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx deleted file mode 100644 index 911715fd..00000000 --- a/src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx +++ /dev/null @@ -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 ( -
- -
- {children} - {hasFilters ? ( -
- -
- ) : null} -
-
- ) -} diff --git a/src/components/campaign/shared/CampaignFilterChips.tsx b/src/components/campaign/shared/CampaignFilterChips.tsx deleted file mode 100644 index e25baa4d..00000000 --- a/src/components/campaign/shared/CampaignFilterChips.tsx +++ /dev/null @@ -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(null) - - const activeHref = - isPending && optimisticHref !== null - ? optimisticHref - : (chips.find((chip) => chip.active)?.href ?? null) - - const interceptNavigation = (event: MouseEvent, 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 ( - - ) -} diff --git a/src/components/campaign/shared/CampaignMobileMultiFilterField.tsx b/src/components/campaign/shared/CampaignMobileMultiFilterField.tsx deleted file mode 100644 index db8bc440..00000000 --- a/src/components/campaign/shared/CampaignMobileMultiFilterField.tsx +++ /dev/null @@ -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 ( - - {label} - { - if (event.target.value) onToggle(event.target.value) - }} - className="min-h-11 w-full" - > - - {selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel} - - {ordered.map((option) => ( - - {selectedSet.has(option.value) ? `✓ ${option.label}` : option.label} - - ))} - - - ) -} diff --git a/src/components/campaign/shared/CampaignSearchForm.tsx b/src/components/campaign/shared/CampaignSearchForm.tsx deleted file mode 100644 index e1d2175c..00000000 --- a/src/components/campaign/shared/CampaignSearchForm.tsx +++ /dev/null @@ -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 | 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 ( -
{ - event.preventDefault() - if (debounceRef.current) clearTimeout(debounceRef.current) - navigateIfChanged(query) - }} - > -

- {isPending ? 'Atualizando resultados…' : ''} -

- 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" - /> - -
- ) -} diff --git a/src/components/campaign/shared/useCampaignFilterValues.ts b/src/components/campaign/shared/useCampaignFilterValues.ts deleted file mode 100644 index 653e9352..00000000 --- a/src/components/campaign/shared/useCampaignFilterValues.ts +++ /dev/null @@ -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 = ({ - 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 } -} diff --git a/src/components/campaign/shared/useCampaignListFilterNavigation.ts b/src/components/campaign/shared/useCampaignListFilterNavigation.ts index a5224cf4..238f4fca 100644 --- a/src/components/campaign/shared/useCampaignListFilterNavigation.ts +++ b/src/components/campaign/shared/useCampaignListFilterNavigation.ts @@ -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: 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, @@ -42,52 +21,13 @@ export const useCampaignListFilterNavigation = ({ }) => { const router = useRouter() const { isPending, startTransition } = useCampaignListTransition() - const debounceRef = useRef | 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 = ({ }) } - /** - * 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, } } diff --git a/src/lib/campaignListOmnibox.ts b/src/lib/campaignListOmnibox.ts index bf9c925a..d0601e8d 100644 --- a/src/lib/campaignListOmnibox.ts +++ b/src/lib/campaignListOmnibox.ts @@ -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). */ diff --git a/src/utilities/territory/territoryListUrl.ts b/src/utilities/territory/territoryListUrl.ts index c9506809..61aa5e8b 100644 --- a/src/utilities/territory/territoryListUrl.ts +++ b/src/utilities/territory/territoryListUrl.ts @@ -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 } -}