diff --git a/docs/plans/adotar-barra-filtros-omnibox-listas-impl.md b/docs/plans/adotar-barra-filtros-omnibox-listas-impl.md new file mode 100644 index 00000000..bcc2e49c --- /dev/null +++ b/docs/plans/adotar-barra-filtros-omnibox-listas-impl.md @@ -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//*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 diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 7493255f..a360fb84 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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', diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index 7e360570..d06c0e5c 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -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 ( - + - buildDemandListHref({ ...state, status }, 1) - return (
@@ -121,17 +117,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
- ({ - href: hrefForStatus(status), - label: campaignDemandStatusLabels[status], - active: state.status === status, - })), - ]} - /> + option !== null) .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')) - const municipalityLabelsById: Record = {} - 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 - +

diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx index 91fc8756..0894f502 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx @@ -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 - + ({ - 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() + 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 (

-
{ + event.preventDefault() + }} > -

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

- replaceValues({ kind: '', status: '', municipality: '' })} - > -
- - Tipo de atividade - - updateValues((current) => ({ ...current, kind: event.target.value })) - } - className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" - > - Todos os tipos - {Object.entries(activityKindLabels).map(([value, label]) => ( - - {label} - - ))} - - - {state.tab === 'todos' ? ( - - Status - - updateValues((current) => ({ ...current, status: event.target.value })) - } - className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" - > - Todos os status - {Object.entries(activityStatusLabels).map(([value, label]) => ( - - {label} - - ))} - - - ) : null} - - Município - - updateValues((current) => ({ ...current, municipality: event.target.value })) - } - className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" - > - Todos os municípios - {municipalityOptions.map((option) => ( - - {option.name} - - ))} - - -
-
-
+ { + runAction(applyActivityOmniboxSuggestion({ state, suggestionId })) + }} + onRemoveChip={(chipId) => { + runAction(removeActivityOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearActivityOmnibox(state)) + }} + /> +
) } diff --git a/src/components/campaign/advisor/AdvisorFilters.tsx b/src/components/campaign/advisor/AdvisorFilters.tsx new file mode 100644 index 00000000..9f5c9748 --- /dev/null +++ b/src/components/campaign/advisor/AdvisorFilters.tsx @@ -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) => { + if (action.kind === 'clear') { + setQuery('') + navigate(action.state) + return + } + navigate(action.state) + } + + return ( +
{ + event.preventDefault() + }} + > + { + 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 }, + }), + ) + }} + /> + + ) +} diff --git a/src/components/campaign/demand/DemandFilters.tsx b/src/components/campaign/demand/DemandFilters.tsx new file mode 100644 index 00000000..f261c903 --- /dev/null +++ b/src/components/campaign/demand/DemandFilters.tsx @@ -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 ( +
{ + event.preventDefault() + }} + > + { + runAction(applyDemandOmniboxSuggestion({ state, suggestionId })) + }} + onRemoveChip={(chipId) => { + runAction(removeDemandOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearDemandOmnibox(state)) + }} + /> + + ) +} diff --git a/src/components/campaign/leadership/LeadershipFilters.tsx b/src/components/campaign/leadership/LeadershipFilters.tsx index f00f18b9..e1d418bb 100644 --- a/src/components/campaign/leadership/LeadershipFilters.tsx +++ b/src/components/campaign/leadership/LeadershipFilters.tsx @@ -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> + 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() + 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 (
{ event.preventDefault() - navigateWithSearch(state) }} > -
- onSearchChange(event.target.value)} - /> - {activeSummary ? ( -

- {activeSummary} -

- ) : null} - {activeSummary ? ( - - ) : null} -
- - {activeSummary ? ( -

{activeSummary}

- ) : null} + { + runAction(applyLeadershipOmniboxSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applyLeadershipOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeLeadershipOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearLeadershipOmnibox(state)) + }} + /> ) } diff --git a/src/components/campaign/municipality/TerritoryFilters.tsx b/src/components/campaign/municipality/TerritoryFilters.tsx index 7e66ead1..85432793 100644 --- a/src/components/campaign/municipality/TerritoryFilters.tsx +++ b/src/components/campaign/municipality/TerritoryFilters.tsx @@ -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 (
{ event.preventDefault() - navigateWithSearch(state) }} > -
- onSearchChange(event.target.value)} - /> - {activeSummary ? ( -

- {activeSummary} -

- ) : null} - {activeSummary ? ( - - ) : null} -
- -
- {activeSummary ?

{activeSummary}

: null} - navigateWithSearch(toggleTerritoryRegionFilter(state, value))} - /> - - - Assessoria - - navigateWithSearch({ - ...state, - coverage: - event.target.value === 'com_assessor' || event.target.value === 'sem_assessor' - ? event.target.value - : undefined, - }) - } - className="w-full [&_select]:h-11" - > - Todas - {Object.entries(territoryCoverageLabels).map(([value, label]) => ( - - {label} - - ))} - - - - - Ordenar - { - 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) => ( - - {option.label} - - ))} - - -
+ { + runAction(applyTerritoryOmniboxSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applyTerritoryOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeTerritoryOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearTerritoryOmnibox(state)) + }} + /> ) } diff --git a/src/components/campaign/organization/OrganizationFilters.tsx b/src/components/campaign/organization/OrganizationFilters.tsx new file mode 100644 index 00000000..839c12aa --- /dev/null +++ b/src/components/campaign/organization/OrganizationFilters.tsx @@ -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) => { + if (action.kind === 'clear') { + setQuery('') + navigate(action.state) + return + } + navigate(action.state) + } + + return ( +
{ + event.preventDefault() + }} + > + { + 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 } : {}) }), + }), + ) + }} + /> + + ) +} 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 54347862..238f4fca 100644 --- a/src/components/campaign/shared/useCampaignListFilterNavigation.ts +++ b/src/components/campaign/shared/useCampaignListFilterNavigation.ts @@ -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 = ({ +export const useCampaignListFilterNavigation = ({ state, toHref, }: { @@ -34,51 +21,13 @@ export const useCampaignListFilterNavigation = ({ }) => { const router = useRouter() const { isPending, startTransition } = useCampaignListTransition() - const debounceRef = useRef | 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 = ({ }) } - /** - * 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, } } diff --git a/src/components/campaign/stateDeputy/StateDeputyFilters.tsx b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx index cfa6a404..8885352f 100644 --- a/src/components/campaign/stateDeputy/StateDeputyFilters.tsx +++ b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx @@ -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 (
{ event.preventDefault() - navigateWithSearch(state) }} > -
- onSearchChange(event.target.value)} - /> - {activeSummary ? ( -

- {activeSummary} -

- ) : null} - {activeSummary ? ( - - ) : null} -
- -
- {activeSummary ?

{activeSummary}

: null} - navigateWithSearch(toggleStateDeputyPartyFilter(state, value))} - /> - - - Ordenar - { - 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) => ( - - {option.label} - - ))} - - -
+ { + runAction(applyStateDeputyOmniboxSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applyStateDeputyOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeStateDeputyOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearStateDeputyOmnibox(state)) + }} + /> ) } diff --git a/src/components/campaign/supporter/SupporterFilters.tsx b/src/components/campaign/supporter/SupporterFilters.tsx index 29b45e17..a075483e 100644 --- a/src/components/campaign/supporter/SupporterFilters.tsx +++ b/src/components/campaign/supporter/SupporterFilters.tsx @@ -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 | null>(null) - useEffect( - () => () => { - if (debounceRef.current) clearTimeout(debounceRef.current) - }, + const municipalityLabelsById = useMemo(() => { + const map = new Map() + 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), - })) - - const hasFilters = filterNames.some((name) => Boolean(values[name])) - return ( -
{ + event.preventDefault() + }} > -

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

-
- onSearchChange(event.target.value)} - placeholder="Buscar por nome, telefone ou município" - enterKeyHint="search" - /> -
- - -
- - Intenção de voto - - updateValues((current) => ({ ...current, voteIntention: event.target.value })) - } - className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" - > - Todas - {Object.entries(supporterVoteIntentionLabels).map(([value, label]) => ( - - {label} - - ))} - - - - Cidade - updateValues((current) => ({ ...current, city }))} - /> - - - Município - - updateValues((current) => ({ ...current, municipality: event.target.value })) - } - className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]" - > - Todas - {municipalityOptions.map((municipality) => ( - - {municipality.name} - - ))} - - -
-
-
+ { + runAction(applySupporterOmniboxSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applySupporterOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeSupporterOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearSupporterOmnibox(state)) + }} + /> + ) } diff --git a/src/lib/campaignListOmnibox.ts b/src/lib/campaignListOmnibox.ts index 6f3cd8e6..d0601e8d 100644 --- a/src/lib/campaignListOmnibox.ts +++ b/src/lib/campaignListOmnibox.ts @@ -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() + + 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 +} diff --git a/src/lib/searchOnlyListOmnibox.ts b/src/lib/searchOnlyListOmnibox.ts new file mode 100644 index 00000000..c60d8931 --- /dev/null +++ b/src/lib/searchOnlyListOmnibox.ts @@ -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 = + | { 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 = ({ + state, + suggestionId, + withPageReset, +}: { + state: T + suggestionId: string + withPageReset: (next: T) => T +}): SearchOnlyOmniboxAction => { + 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 = ({ + state, + chipId, + withPageReset, +}: { + state: T + chipId: string + withPageReset: (next: T) => T +}): SearchOnlyOmniboxAction => { + if (chipId === 'q') { + return { kind: 'url', state: withPageReset({ ...state, q: undefined }) } + } + return { kind: 'url', state } +} + +export const clearSearchOnlyOmnibox = ({ + cleared, +}: { + state: T + cleared: T +}): SearchOnlyOmniboxAction => ({ + kind: 'clear', + state: cleared, +}) diff --git a/src/utilities/activityOmnibox.ts b/src/utilities/activityOmnibox.ts new file mode 100644 index 00000000..071c3a57 --- /dev/null +++ b/src/utilities/activityOmnibox.ts @@ -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, +): ActivityListState => parseActivityListParams(raw) + +const setExclusiveField = ( + state: ActivityListState, + field: 'kind' | 'status' | 'municipality', + value: string | undefined, +): ActivityListState => { + const params = buildActivityListSearchParams(withPageReset(state)) + const raw: Record = 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 +}): 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, + 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 }), +}) diff --git a/src/utilities/advisor/advisorListUrl.ts b/src/utilities/advisor/advisorListUrl.ts new file mode 100644 index 00000000..d5391e94 --- /dev/null +++ b/src/utilities/advisor/advisorListUrl.ts @@ -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) diff --git a/src/utilities/advisorData.ts b/src/utilities/advisorData.ts index 310a6e99..4c281065 100644 --- a/src/utilities/advisorData.ts +++ b/src/utilities/advisorData.ts @@ -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 = ( diff --git a/src/utilities/campaignDemandData.ts b/src/utilities/campaignDemandData.ts index d34b04fe..fc1e3209 100644 --- a/src/utilities/campaignDemandData.ts +++ b/src/utilities/campaignDemandData.ts @@ -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[], diff --git a/src/utilities/demand/demandListUrl.ts b/src/utilities/demand/demandListUrl.ts new file mode 100644 index 00000000..0d59046b --- /dev/null +++ b/src/utilities/demand/demandListUrl.ts @@ -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) diff --git a/src/utilities/demand/demandOmnibox.ts b/src/utilities/demand/demandOmnibox.ts new file mode 100644 index 00000000..bf3f1975 --- /dev/null +++ b/src/utilities/demand/demandOmnibox.ts @@ -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, + 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 } : {}), + }), +}) diff --git a/src/utilities/leadership/leadershipOmnibox.ts b/src/utilities/leadership/leadershipOmnibox.ts new file mode 100644 index 00000000..1c40f6a3 --- /dev/null +++ b/src/utilities/leadership/leadershipOmnibox.ts @@ -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 +}): 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, + 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), +}) diff --git a/src/utilities/organization/organizationListUrl.ts b/src/utilities/organization/organizationListUrl.ts new file mode 100644 index 00000000..e3059130 --- /dev/null +++ b/src/utilities/organization/organizationListUrl.ts @@ -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) diff --git a/src/utilities/organizationData.ts b/src/utilities/organizationData.ts index b63f69fa..d59c162e 100644 --- a/src/utilities/organizationData.ts +++ b/src/utilities/organizationData.ts @@ -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[], diff --git a/src/utilities/stateDeputyOmnibox.ts b/src/utilities/stateDeputyOmnibox.ts new file mode 100644 index 00000000..b204949c --- /dev/null +++ b/src/utilities/stateDeputyOmnibox.ts @@ -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, + 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), +}) diff --git a/src/utilities/supporter/supporterOmnibox.ts b/src/utilities/supporter/supporterOmnibox.ts new file mode 100644 index 00000000..9c9917ae --- /dev/null +++ b/src/utilities/supporter/supporterOmnibox.ts @@ -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, +): SupporterListState => parseSupporterListParams(raw) + +export const buildSupporterOmniboxChips = ({ + state, + municipalityLabelsById, +}: { + state: SupporterListState + municipalityLabelsById: ReadonlyMap +}): 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, + 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 = 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 }), +}) 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 } -} diff --git a/src/utilities/territory/territoryOmnibox.ts b/src/utilities/territory/territoryOmnibox.ts new file mode 100644 index 00000000..f1a078d2 --- /dev/null +++ b/src/utilities/territory/territoryOmnibox.ts @@ -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, + 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), +}) diff --git a/tests/unit/campaignListFilterNavigation.unit.spec.ts b/tests/unit/campaignListFilterNavigation.unit.spec.ts index 42678634..1785f88a 100644 --- a/tests/unit/campaignListFilterNavigation.unit.spec.ts +++ b/tests/unit/campaignListFilterNavigation.unit.spec.ts @@ -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 }) - }, - ) }) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 32ad2262..453425c9 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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', diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts new file mode 100644 index 00000000..02ab4b39 --- /dev/null +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -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' } }) + }) +})