From c9fdc59ed2e2922f26843c0e7cc26af1f4d01df3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:04:45 +0000 Subject: [PATCH 1/3] =?UTF-8?q?B143=20=E2=80=94=20omnibox=20de=20lideran?= =?UTF-8?q?=C3=A7as=20filtra=20por=20organiza=C3=A7=C3=A3o=20e=20dobradinh?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - URL state: organization / stateDeputy (OR inclusivo por dimensão) - Facets cross-filtered em leadershipData - Adapter omnibox + wiring na página - Testes unit/int e plano de implementação Co-authored-by: Francisco Solla --- .../liderancas-omnibox-org-dobradinha-impl.md | 63 ++++++++++++++ .../campanha/(app)/liderancas/page.tsx | 32 ++++++- .../campaign/leadership/LeadershipFilters.tsx | 49 +++++++++-- src/utilities/leadership/leadershipData.ts | 84 +++++++++++++++++-- .../leadership/leadershipListFilters.ts | 28 ++++++- src/utilities/leadership/leadershipListUrl.ts | 26 ++++++ src/utilities/leadership/leadershipOmnibox.ts | 80 ++++++++++++++++++ tests/int/campaignEntityListData.int.spec.ts | 34 +++++++- tests/unit/leadershipListUrl.unit.spec.ts | 37 +++++++- tests/unit/listOmniboxB128.unit.spec.ts | 27 +++++- 10 files changed, 444 insertions(+), 16 deletions(-) create mode 100644 docs/plans/liderancas-omnibox-org-dobradinha-impl.md diff --git a/docs/plans/liderancas-omnibox-org-dobradinha-impl.md b/docs/plans/liderancas-omnibox-org-dobradinha-impl.md new file mode 100644 index 00000000..63daaecd --- /dev/null +++ b/docs/plans/liderancas-omnibox-org-dobradinha-impl.md @@ -0,0 +1,63 @@ +# Impl: Lideranças — filtrar por organização e dobradinha na omnibox + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #311 +Intenção: docs/plans/liderancas-omnibox-org-dobradinha.md +Appetite restante: herdado (~0,5–1 dia eng) + +## Leitura da intenção + +- **Outcome:** Staff filtra a lista `/campanha/liderancas` por organização e/ou dobradinha via omnibox (sugestões tipadas + chips), com semântica OR inclusiva por dimensão, sem expor estimativas. +- **O que NÃO negociar:** lista continua staff-only; sem criação de org/dobradinha na barra; sem filtro “sem organização/dobradinha”; sem estimatedVotes. +- **O que reavaliar:** facet cross-filtered (como município) em vez de carregar catálogo inteiro; params URL `organization` / `stateDeputy` (singular, espelho de `municipality`). + +## Abordagem recomendada + +```mermaid +flowchart LR + URL["leadershipListUrl"] --> Where["buildLeadershipListWhere"] + URL --> Omnibox["leadershipOmnibox"] + Data["leadershipData facets"] --> Page["liderancas/page"] + Page --> Filters["LeadershipFilters"] + Filters --> Omnibox +``` + +**Opções consideradas:** A) facet cross-filtered + omnibox · B) catálogo completo no client · C) só uma dimensão +**Recomendação:** A — precedente município/status; busca tipada escala; URL canônica reutiliza `campaignListUrl` helpers. +**Rejeitadas:** B (lista grande, sem cross-filter); C (aceite pede ambas). + +### Componentes / mudanças + +- **`leadershipListUrl.ts`:** state `organizations` / `stateDeputies`; parse/serialize `organization` / `stateDeputy`; `where` com `in` OR. +- **`leadershipListFilters.ts`:** toggles multi-valor (mesmo contrato de município). +- **`leadershipData.ts`:** facets `organizationIDs` / `stateDeputyIDs` (ignoram próprio filtro, unem seleção). +- **`leadershipOmnibox.ts`:** chips, seeds, apply/remove para `organization:` / `stateDeputy:`. +- **`LeadershipFilters.tsx` + `page.tsx`:** opções de facet com rótulos; placeholder atualizado. +- **Migration:** sem migration. +- **Access / Consent:** inalterado (staff guard existente). +- **UI:** Impeccable B — encaixe na omnibox existente; sem header filter novo. + +## Fases verificáveis + +1. **URL + where + filters** — parsers, toggles, `buildLeadershipListWhere`; unit `leadershipListUrl`. +2. **Facets + page wiring** — `leadershipData`; int filter por organização. +3. **Omnibox** — adapter + `LeadershipFilters`; unit B128. +4. **Gates** — `pnpm gate:fast`; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Header filter popover para org/dobradinha (só omnibox neste item). +- Saved filters; sentinela “sem vínculo”. +- Alterar omnibox de organizações/dobradinhas (Issues irmãs). + +## Riscos e mitigação + +- **Facet pesado:** `select` só do campo relacional, `limit: 0` — mesmo padrão município. +- **Rótulo de chip ausente:** unir ID selecionado no facet mesmo fora do recorte atual. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio previstos (unit URL/omnibox; int filter org) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index acfa524f..edd2a0ff 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -55,6 +55,7 @@ import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibili import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' import { formatRelativeAge } from '@/utilities/formatRelativeAge' +import { loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds' import { loadLeadershipListPageData, type LeadershipRowViewModel, @@ -74,6 +75,7 @@ import { type LeadershipListState, } from '@/utilities/leadership/leadershipListUrl' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' +import { loadStateDeputySummaries } from '@/utilities/stateDeputyData' import { setLeadershipMunicipalitiesFormAction, @@ -352,6 +354,29 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP .filter((option): option is LeadershipFilterOption => option !== null) .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')) + const [organizationNames, stateDeputySummaries] = await Promise.all([ + loadOrganizationNamesByIds(payload, filterFacets.organizationIDs), + loadStateDeputySummaries(payload, filterFacets.stateDeputyIDs), + ]) + + const organizationFilterOptions: LeadershipFilterOption[] = filterFacets.organizationIDs + .map((id) => { + const label = organizationNames.get(id) + return label ? { value: String(id), label } : null + }) + .filter((option): option is LeadershipFilterOption => option !== null) + .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')) + + const stateDeputyFilterOptions: LeadershipFilterOption[] = filterFacets.stateDeputyIDs + .map((id) => { + const summary = stateDeputySummaries.find((entry) => entry.id === id) + if (!summary) return null + const label = summary.party ? `${summary.name} (${summary.party})` : summary.name + return { value: String(id), label } + }) + .filter((option): option is LeadershipFilterOption => option !== null) + .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')) + const { sort, dir } = resolveLeadershipListSort(state) const sortSummary = formatLeadershipListSortSummary(sort, dir) const columns = leadershipColumns({ @@ -384,7 +409,12 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP - +

diff --git a/src/components/campaign/leadership/LeadershipFilters.tsx b/src/components/campaign/leadership/LeadershipFilters.tsx index e1d418bb..981256c4 100644 --- a/src/components/campaign/leadership/LeadershipFilters.tsx +++ b/src/components/campaign/leadership/LeadershipFilters.tsx @@ -22,9 +22,13 @@ import { export const LeadershipFilters = ({ state, municipalityFilterOptions, + organizationFilterOptions, + stateDeputyFilterOptions, }: { state: LeadershipListState municipalityFilterOptions: LeadershipFilterOption[] + organizationFilterOptions: LeadershipFilterOption[] + stateDeputyFilterOptions: LeadershipFilterOption[] }) => { const { navigate, isPending } = useCampaignListFilterNavigation({ state, @@ -41,14 +45,49 @@ export const LeadershipFilters = ({ return map }, [municipalityFilterOptions]) + const organizationLabelsById = useMemo(() => { + const map = new Map() + for (const option of organizationFilterOptions) { + const id = Number(option.value) + if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label) + } + for (const organizationId of state.organizations ?? []) { + if (!map.has(organizationId)) map.set(organizationId, `Organização #${organizationId}`) + } + return map + }, [organizationFilterOptions, state.organizations]) + + const stateDeputyLabelsById = useMemo(() => { + const map = new Map() + for (const option of stateDeputyFilterOptions) { + const id = Number(option.value) + if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label) + } + for (const stateDeputyId of state.stateDeputies ?? []) { + if (!map.has(stateDeputyId)) map.set(stateDeputyId, `Dobradinha #${stateDeputyId}`) + } + return map + }, [stateDeputyFilterOptions, state.stateDeputies]) + const chips = useMemo( - () => buildLeadershipOmniboxChips({ state, municipalityLabelsById }), - [state, municipalityLabelsById], + () => + buildLeadershipOmniboxChips({ + state, + municipalityLabelsById, + organizationLabelsById, + stateDeputyLabelsById, + }), + [state, municipalityLabelsById, organizationLabelsById, stateDeputyLabelsById], ) const suggestionSeeds = useMemo( - () => buildLeadershipOmniboxSuggestionSeeds({ municipalityFilterOptions }), - [municipalityFilterOptions], + () => + buildLeadershipOmniboxSuggestionSeeds({ + municipalityFilterOptions, + organizationFilterOptions, + stateDeputyFilterOptions, + }), + [municipalityFilterOptions, organizationFilterOptions, stateDeputyFilterOptions], ) const suggestions = useMemo( @@ -75,7 +114,7 @@ export const LeadershipFilters = ({ => { +): Promise => { const result = await payload.find({ collection: 'leadership', where: buildLeadershipListWhere({ ...state, municipalities: undefined }), @@ -220,9 +224,74 @@ const loadLeadershipMunicipalityFacet = async ( } } - return { - municipalityIDs: [...available].sort((left, right) => left - right), + return [...available].sort((left, right) => left - right) +} + +const loadLeadershipOrganizationFacet = async ( + payload: Payload, + user: CampaignUser, + state: LeadershipListState, +): Promise => { + const result = await payload.find({ + collection: 'leadership', + where: buildLeadershipListWhere({ ...state, organizations: undefined }), + depth: 0, + limit: 0, + pagination: false, + select: { organizations: true }, + user, + overrideAccess: false, + }) + + const available = new Set(state.organizations ?? []) + for (const doc of result.docs) { + for (const organization of doc.organizations ?? []) { + const id = relationshipId(organization) + if (id !== null) available.add(id) + } } + + return [...available].sort((left, right) => left - right) +} + +const loadLeadershipStateDeputyFacet = async ( + payload: Payload, + user: CampaignUser, + state: LeadershipListState, +): Promise => { + const result = await payload.find({ + collection: 'leadership', + where: buildLeadershipListWhere({ ...state, stateDeputies: undefined }), + depth: 0, + limit: 0, + pagination: false, + select: { stateDeputies: true }, + user, + overrideAccess: false, + }) + + const available = new Set(state.stateDeputies ?? []) + for (const doc of result.docs) { + for (const stateDeputy of doc.stateDeputies ?? []) { + const id = relationshipId(stateDeputy) + if (id !== null) available.add(id) + } + } + + return [...available].sort((left, right) => left - right) +} + +const loadLeadershipFilterFacets = async ( + payload: Payload, + user: CampaignUser, + state: LeadershipListState, +): Promise => { + const [municipalityIDs, organizationIDs, stateDeputyIDs] = await Promise.all([ + loadLeadershipMunicipalityFacet(payload, user, state), + loadLeadershipOrganizationFacet(payload, user, state), + loadLeadershipStateDeputyFacet(payload, user, state), + ]) + return { municipalityIDs, organizationIDs, stateDeputyIDs } } export const loadLeadershipListPageData = async ( @@ -236,7 +305,12 @@ export const loadLeadershipListPageData = async ( filterFacets: LeadershipListFilterFacets }> => { if (!isCampaignStaff(user)) { - return { rows: [], totalDocs: 0, totalPages: 0, filterFacets: { municipalityIDs: [] } } + return { + rows: [], + totalDocs: 0, + totalPages: 0, + filterFacets: { municipalityIDs: [], organizationIDs: [], stateDeputyIDs: [] }, + } } const { sort, dir } = resolveLeadershipListSort(state) @@ -251,7 +325,7 @@ export const loadLeadershipListPageData = async ( user, overrideAccess: false, }), - loadLeadershipMunicipalityFacet(payload, user, state), + loadLeadershipFilterFacets(payload, user, state), ]) return { diff --git a/src/utilities/leadership/leadershipListFilters.ts b/src/utilities/leadership/leadershipListFilters.ts index d27f7fc4..1c97c036 100644 --- a/src/utilities/leadership/leadershipListFilters.ts +++ b/src/utilities/leadership/leadershipListFilters.ts @@ -22,7 +22,7 @@ export type LeadershipFilterOption = { label: string } -type LeadershipMultiFilterParam = 'status' | 'municipality' +type LeadershipMultiFilterParam = 'status' | 'municipality' | 'organization' | 'stateDeputy' export const leadershipStatusFilterOptions: LeadershipFilterOption[] = leadershipSupportStatuses.map((value) => ({ value, label: supportStatusLabels[value] })) @@ -53,6 +53,8 @@ const getLeadershipMultiFilterValues = ( param: LeadershipMultiFilterParam, ): string[] => { if (param === 'status') return state.statuses ?? [] + if (param === 'organization') return (state.organizations ?? []).map(String) + if (param === 'stateDeputy') return (state.stateDeputies ?? []).map(String) return (state.municipalities ?? []).map(String) } @@ -89,6 +91,30 @@ export const clearLeadershipMunicipalityFilter = ( state: LeadershipListState, ): LeadershipListState => setLeadershipMultiFilterValues(state, 'municipality', []) +export const toggleLeadershipOrganizationFilter = ( + state: LeadershipListState, + value: string, +): LeadershipListState => { + if (strictDecimalInteger(value) === undefined) return state + return toggleLeadershipMultiFilterValue(state, 'organization', value) +} + +export const clearLeadershipOrganizationFilter = ( + state: LeadershipListState, +): LeadershipListState => setLeadershipMultiFilterValues(state, 'organization', []) + +export const toggleLeadershipStateDeputyFilter = ( + state: LeadershipListState, + value: string, +): LeadershipListState => { + if (strictDecimalInteger(value) === undefined) return state + return toggleLeadershipMultiFilterValue(state, 'stateDeputy', value) +} + +export const clearLeadershipStateDeputyFilter = ( + state: LeadershipListState, +): LeadershipListState => setLeadershipMultiFilterValues(state, 'stateDeputy', []) + /** Exclusive toggle — selecting the active value clears; selecting the other replaces. */ export const toggleLeadershipAccessFilter = ( state: LeadershipListState, diff --git a/src/utilities/leadership/leadershipListUrl.ts b/src/utilities/leadership/leadershipListUrl.ts index 2f7cd5e9..90ab9efd 100644 --- a/src/utilities/leadership/leadershipListUrl.ts +++ b/src/utilities/leadership/leadershipListUrl.ts @@ -31,6 +31,8 @@ export type LeadershipListState = { q?: string statuses?: SupportStatus[] municipalities?: number[] + organizations?: number[] + stateDeputies?: number[] access?: LeadershipListAccessFilter sort?: LeadershipListSortKey dir?: LeadershipListSortDirection @@ -42,6 +44,8 @@ const leadershipListParamNames = [ 'q', 'status', 'municipality', + 'organization', + 'stateDeputy', 'access', 'sort', 'dir', @@ -105,6 +109,8 @@ export const leadershipListStateToRawParams = ( q: state.q, status: state.statuses, municipality: state.municipalities?.map(String), + organization: state.organizations?.map(String), + stateDeputy: state.stateDeputies?.map(String), access: state.access, sort: state.sort, dir: state.dir, @@ -119,6 +125,12 @@ export const parseLeadershipListParams = ( const municipalities = allParamValues(params.municipality) .map((token) => strictDecimalInteger(token)) .filter((id): id is number => typeof id === 'number' && id > 0) + const organizations = allParamValues(params.organization) + .map((token) => strictDecimalInteger(token)) + .filter((id): id is number => typeof id === 'number' && id > 0) + const stateDeputies = allParamValues(params.stateDeputy) + .map((token) => strictDecimalInteger(token)) + .filter((id): id is number => typeof id === 'number' && id > 0) const rawAccess = firstValue(params.access) const access = rawAccess && accessFilterSet.has(rawAccess as LeadershipListAccessFilter) @@ -140,6 +152,8 @@ export const parseLeadershipListParams = ( ...(q ? { q } : {}), ...(statuses.length ? { statuses } : {}), ...(municipalities.length ? { municipalities } : {}), + ...(organizations.length ? { organizations } : {}), + ...(stateDeputies.length ? { stateDeputies } : {}), ...(access ? { access } : {}), ...(sort ? { sort } : {}), ...(dir ? { dir } : {}), @@ -153,6 +167,12 @@ export const buildLeadershipListWhere = (state: LeadershipListState): Where => { if (state.municipalities?.length) { filters.push({ municipalities: { in: state.municipalities } }) } + if (state.organizations?.length) { + filters.push({ organizations: { in: state.organizations } }) + } + if (state.stateDeputies?.length) { + filters.push({ stateDeputies: { in: state.stateDeputies } }) + } if (state.access) { filters.push({ user: { exists: state.access === 'com' } }) } @@ -175,6 +195,12 @@ export const serializeCanonicalLeadershipListSearchParams = ( for (const municipality of canonicalState.municipalities ?? []) { params.append('municipality', String(municipality)) } + for (const organization of canonicalState.organizations ?? []) { + params.append('organization', String(organization)) + } + for (const stateDeputy of canonicalState.stateDeputies ?? []) { + params.append('stateDeputy', String(stateDeputy)) + } if (canonicalState.access) params.set('access', canonicalState.access) if (!isSortDefault) { params.set('sort', resolvedSort) diff --git a/src/utilities/leadership/leadershipOmnibox.ts b/src/utilities/leadership/leadershipOmnibox.ts index 1c40f6a3..ed412d01 100644 --- a/src/utilities/leadership/leadershipOmnibox.ts +++ b/src/utilities/leadership/leadershipOmnibox.ts @@ -17,6 +17,8 @@ import { leadershipStatusFilterOptions, toggleLeadershipAccessFilter, toggleLeadershipMunicipalityFilter, + toggleLeadershipOrganizationFilter, + toggleLeadershipStateDeputyFilter, toggleLeadershipStatusFilter, type LeadershipFilterOption, } from '@/utilities/leadership/leadershipListFilters' @@ -64,9 +66,13 @@ const leadershipListSortOptions = ( export const buildLeadershipOmniboxChips = ({ state, municipalityLabelsById, + organizationLabelsById, + stateDeputyLabelsById, }: { state: LeadershipListState municipalityLabelsById: ReadonlyMap + organizationLabelsById: ReadonlyMap + stateDeputyLabelsById: ReadonlyMap }): CampaignListOmniboxChip[] => { const chips: CampaignListOmniboxChip[] = [] @@ -86,6 +92,26 @@ export const buildLeadershipOmniboxChips = ({ }) } + for (const organizationId of state.organizations ?? []) { + chips.push({ + id: `organization:${organizationId}`, + label: chipLabel( + 'Organização', + organizationLabelsById.get(organizationId) ?? `Organização #${organizationId}`, + ), + }) + } + + for (const stateDeputyId of state.stateDeputies ?? []) { + chips.push({ + id: `stateDeputy:${stateDeputyId}`, + label: chipLabel( + 'Dobradinha', + stateDeputyLabelsById.get(stateDeputyId) ?? `Dobradinha #${stateDeputyId}`, + ), + }) + } + if (state.access) { chips.push({ id: `access:${state.access}`, @@ -109,8 +135,12 @@ export const buildLeadershipOmniboxChips = ({ export const buildLeadershipOmniboxSuggestionSeeds = ({ municipalityFilterOptions, + organizationFilterOptions, + stateDeputyFilterOptions, }: { municipalityFilterOptions: readonly LeadershipFilterOption[] + organizationFilterOptions: readonly LeadershipFilterOption[] + stateDeputyFilterOptions: readonly LeadershipFilterOption[] }) => { const seeds = [] @@ -139,6 +169,28 @@ export const buildLeadershipOmniboxSuggestionSeeds = ({ ) } + for (const option of organizationFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed({ + id: `organization:${option.value}`, + group: 'Organização', + label: option.label, + keywords: ['organizacao', 'org', 'sindicato'], + }), + ) + } + + for (const option of stateDeputyFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed({ + id: `stateDeputy:${option.value}`, + group: 'Dobradinha', + label: option.label, + keywords: ['dobradinha', 'deputado'], + }), + ) + } + for (const option of leadershipAccessFilterOptions) { seeds.push( createOmniboxSuggestionSeed( @@ -198,6 +250,20 @@ export const applyLeadershipOmniboxSuggestion = ({ } } + if (suggestionId.startsWith('organization:')) { + return { + kind: 'url', + state: toggleLeadershipOrganizationFilter(state, suggestionId.slice(13)), + } + } + + if (suggestionId.startsWith('stateDeputy:')) { + return { + kind: 'url', + state: toggleLeadershipStateDeputyFilter(state, suggestionId.slice(12)), + } + } + if (suggestionId.startsWith('access:')) { return { kind: 'url', @@ -252,6 +318,20 @@ export const removeLeadershipOmniboxChip = ({ } } + if (chipId.startsWith('organization:')) { + return { + kind: 'url', + state: toggleLeadershipOrganizationFilter(state, chipId.slice(13)), + } + } + + if (chipId.startsWith('stateDeputy:')) { + return { + kind: 'url', + state: toggleLeadershipStateDeputyFilter(state, chipId.slice(12)), + } + } + if (chipId.startsWith('access:')) { return { kind: 'url', state: toggleLeadershipAccessFilter(state, chipId.slice(7)) } } diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts index bb5fb0a5..6a7aca96 100644 --- a/tests/int/campaignEntityListData.int.spec.ts +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -68,7 +68,7 @@ describe('loadLeadershipListPageData', () => { rows: [], totalDocs: 0, totalPages: 0, - filterFacets: { municipalityIDs: [] }, + filterFacets: { municipalityIDs: [], organizationIDs: [], stateDeputyIDs: [] }, }) }) @@ -82,10 +82,40 @@ describe('loadLeadershipListPageData', () => { rows: [], totalDocs: 0, totalPages: 0, - filterFacets: { municipalityIDs: [] }, + filterFacets: { municipalityIDs: [], organizationIDs: [], stateDeputyIDs: [] }, }) }) + it('filters by organization with inclusive OR within the dimension', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const marker = fixtures.value('lead-org-filter') + const organizationA = await fixtures.createOrganization({ name: `Org A ${marker}` }) + const organizationB = await fixtures.createOrganization({ name: `Org B ${marker}` }) + const matchContact = await fixtures.createContact({ name: `Ana ${marker}` }) + const missContact = await fixtures.createContact({ name: `Bruno ${marker}` }) + const match = await fixtures.createLeadership({ + contact: matchContact.id, + municipalities: [municipality.id], + organizations: [organizationA.id], + }) + await fixtures.createLeadership({ + contact: missContact.id, + municipalities: [municipality.id], + organizations: [organizationB.id], + }) + + const byOrganization = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + organizations: [organizationA.id], + }) + expect(byOrganization.rows.map((row) => row.id)).toEqual([match.id]) + expect(byOrganization.filterFacets.organizationIDs).toContain(organizationA.id) + expect(byOrganization.filterFacets.organizationIDs).toContain(organizationB.id) + }) + it('filters by supportStatus, access and municipality', async () => { const fixtures = campaignFixtures() const coordinator = await fixtures.createCampaignUser('coordinator') diff --git a/tests/unit/leadershipListUrl.unit.spec.ts b/tests/unit/leadershipListUrl.unit.spec.ts index 224afe46..90d5877d 100644 --- a/tests/unit/leadershipListUrl.unit.spec.ts +++ b/tests/unit/leadershipListUrl.unit.spec.ts @@ -4,10 +4,14 @@ import { clearLeadershipAccessFilter, clearLeadershipListFilters, clearLeadershipMunicipalityFilter, + clearLeadershipOrganizationFilter, + clearLeadershipStateDeputyFilter, clearLeadershipStatusFilter, formatLeadershipActiveFiltersSummary, toggleLeadershipAccessFilter, toggleLeadershipMunicipalityFilter, + toggleLeadershipOrganizationFilter, + toggleLeadershipStateDeputyFilter, toggleLeadershipStatusFilter, } from '@/utilities/leadership/leadershipListFilters' import { @@ -44,11 +48,15 @@ describe('leadership list URL contract', () => { status: ['engajado', 'a_abordar', 'engajado', 'unknown'], sector: ['religioso'], municipality: ['12', '12', '0', 'abc', '3'], + organization: ['5', '5', '0', 'abc', '8'], + stateDeputy: ['21', '21', '0', 'abc', '34'], }), ).toEqual({ page: 1, statuses: ['engajado', 'a_abordar'], municipalities: [12, 3], + organizations: [5, 8], + stateDeputies: [21, 34], }) }) @@ -84,12 +92,14 @@ describe('leadership list URL contract', () => { status: ['a_abordar'], sector: ['religioso'], municipality: ['7'], + organization: ['5', '8'], + stateDeputy: ['21'], access: 'sem', sort: 'name', dir: 'asc', }) expect(buildLeadershipListHref(state, 1)).toBe( - '/campanha/liderancas?q=ana&status=a_abordar&municipality=7&access=sem&sort=name', + '/campanha/liderancas?q=ana&status=a_abordar&municipality=7&organization=5&organization=8&stateDeputy=21&access=sem&sort=name', ) }) @@ -155,6 +165,16 @@ describe('leadership list filter state', () => { expect(clearLeadershipAccessFilter(withAccess)).toEqual({ page: 1 }) }) + it('toggles organization and stateDeputy filters and resets the page', () => { + const withOrganization = toggleLeadershipOrganizationFilter({ page: 2 }, '15') + expect(withOrganization).toEqual({ page: 1, organizations: [15] }) + expect(toggleLeadershipOrganizationFilter(withOrganization, '15')).toEqual({ page: 1 }) + + const withStateDeputy = toggleLeadershipStateDeputyFilter({ page: 2 }, '9') + expect(withStateDeputy).toEqual({ page: 1, stateDeputies: [9] }) + expect(toggleLeadershipStateDeputyFilter(withStateDeputy, '9')).toEqual({ page: 1 }) + }) + it('clears only one filter, keeping q and sort', () => { expect( clearLeadershipStatusFilter({ @@ -172,6 +192,21 @@ describe('leadership list filter state', () => { access: 'sem', }), ).toEqual({ page: 1, access: 'sem' }) + expect( + clearLeadershipOrganizationFilter({ + page: 1, + organizations: [5], + q: 'ana', + }), + ).toEqual({ page: 1, q: 'ana' }) + expect( + clearLeadershipStateDeputyFilter({ + page: 1, + stateDeputies: [3], + sort: 'name', + dir: 'asc', + }), + ).toEqual({ page: 1, sort: 'name', dir: 'asc' }) }) it('clears every filter and the search, keeping sort', () => { diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts index 02ab4b39..94c5e716 100644 --- a/tests/unit/listOmniboxB128.unit.spec.ts +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -69,11 +69,36 @@ describe('list omnibox adapters (B128)', () => { expect(applied.state.statuses).toContain('engajado') } - const seeds = buildLeadershipOmniboxSuggestionSeeds({ municipalityFilterOptions: [] }) + const seeds = buildLeadershipOmniboxSuggestionSeeds({ + municipalityFilterOptions: [], + organizationFilterOptions: [], + stateDeputyFilterOptions: [], + }) const suggestions = filterLeadershipOmniboxSuggestions(seeds, 'ordenar') expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true) }) + it('leadership applies organization and stateDeputy toggles', () => { + const base = parseLeadershipListParams({}) + const organization = applyLeadershipOmniboxSuggestion({ + state: base, + suggestionId: 'organization:12', + }) + expect(organization.kind).toBe('url') + if (organization.kind === 'url') { + expect(organization.state.organizations).toEqual([12]) + } + + const stateDeputy = applyLeadershipOmniboxSuggestion({ + state: base, + suggestionId: 'stateDeputy:7', + }) + expect(stateDeputy.kind).toBe('url') + if (stateDeputy.kind === 'url') { + expect(stateDeputy.state.stateDeputies).toEqual([7]) + } + }) + it('state deputy toggles party filter', () => { const base = parseStateDeputyListParams({}) const applied = applyStateDeputyOmniboxSuggestion({ From 7563fe45c350c7443dd6fd70856b3fdb95f6f3bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:05:50 +0000 Subject: [PATCH 2/3] =?UTF-8?q?B143=20=E2=80=94=20usa=20Map=20para=20r?= =?UTF-8?q?=C3=B3tulos=20de=20dobradinha=20no=20facet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Francisco Solla --- src/app/(campaign)/campanha/(app)/liderancas/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index edd2a0ff..d6beb032 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -367,9 +367,10 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP .filter((option): option is LeadershipFilterOption => option !== null) .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')) + const stateDeputyById = new Map(stateDeputySummaries.map((summary) => [summary.id, summary])) const stateDeputyFilterOptions: LeadershipFilterOption[] = filterFacets.stateDeputyIDs .map((id) => { - const summary = stateDeputySummaries.find((entry) => entry.id === id) + const summary = stateDeputyById.get(id) if (!summary) return null const label = summary.party ? `${summary.name} (${summary.party})` : summary.name return { value: String(id), label } From 6d358f71ba5e06da0d93bc1122901fe0a90e6a22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:06:34 +0000 Subject: [PATCH 3/3] =?UTF-8?q?B143=20=E2=80=94=20formata=C3=A7=C3=A3o=20P?= =?UTF-8?q?rettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(campaign)/campanha/(app)/liderancas/page.tsx | 2 +- src/utilities/leadership/leadershipListFilters.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index d6beb032..b6c37b32 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -55,7 +55,6 @@ import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibili import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' import { formatRelativeAge } from '@/utilities/formatRelativeAge' -import { loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds' import { loadLeadershipListPageData, type LeadershipRowViewModel, @@ -74,6 +73,7 @@ import { resolveLeadershipListUrl, type LeadershipListState, } from '@/utilities/leadership/leadershipListUrl' +import { loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { loadStateDeputySummaries } from '@/utilities/stateDeputyData' diff --git a/src/utilities/leadership/leadershipListFilters.ts b/src/utilities/leadership/leadershipListFilters.ts index 1c97c036..0e78c935 100644 --- a/src/utilities/leadership/leadershipListFilters.ts +++ b/src/utilities/leadership/leadershipListFilters.ts @@ -111,9 +111,8 @@ export const toggleLeadershipStateDeputyFilter = ( return toggleLeadershipMultiFilterValue(state, 'stateDeputy', value) } -export const clearLeadershipStateDeputyFilter = ( - state: LeadershipListState, -): LeadershipListState => setLeadershipMultiFilterValues(state, 'stateDeputy', []) +export const clearLeadershipStateDeputyFilter = (state: LeadershipListState): LeadershipListState => + setLeadershipMultiFilterValues(state, 'stateDeputy', []) /** Exclusive toggle — selecting the active value clears; selecting the other replaces. */ export const toggleLeadershipAccessFilter = (