Merge pull request #326 from fsolla/cursor/pool-i310-73006baf-d8e9
B142 — Assessores — filtrar por município da carteira na omnibox
This commit is contained in:
commit
8a60b6f0f7
11 changed files with 437 additions and 39 deletions
76
docs/plans/assessores-omnibox-municipio-carteira-impl.md
Normal file
76
docs/plans/assessores-omnibox-municipio-carteira-impl.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Impl: Assessores — filtrar por município da carteira na omnibox
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-02
|
||||
Issue: #310
|
||||
Intenção: docs/plans/assessores-omnibox-municipio-carteira.md
|
||||
Appetite restante: ~0,5 dia eng
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** Coordenador/Candidato filtra assessores pela carteira (município administrado) via omnibox, com busca por nome/e-mail preservada e chips removíveis.
|
||||
- **O que NÃO negociar:** rota continua `unrestricted`; sem filtro “sem carteira”; sem saved filters; sem mudança de RBAC.
|
||||
- **O que reavaliar:** sair de `searchOnlyListOmnibox` — confirmado; relação assessor↔município é inversa (`municipality.advisors`), não campo em `campaignUser`.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
URL["URL ?municipality=&q="] --> Parse["advisorListUrl"]
|
||||
Parse --> Load["loadAdvisorListPageData"]
|
||||
Load --> Resolve["advisorIds via municipality.advisors"]
|
||||
Resolve --> Query["campaignUser role=advisor"]
|
||||
Index["municipalityPortfolioIndex + catalog"] --> Omnibox["advisorOmnibox"]
|
||||
Omnibox --> UI["AdvisorFilters"]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- A) Estender `searchOnlyListOmnibox` com hooks — rejeitada (não escala; leadership/supporter já têm adapter próprio).
|
||||
- B) Adapter `advisorOmnibox` + `advisorListFilters` espelhando lideranças (só dimensão município + busca) — **recomendada**.
|
||||
- C) Facet server-side de municípios com assessor — rejeitada nesta fatia (busca tipada no índice de 435 já atende; página já carrega `loadMunicipalityPortfolioIndex`).
|
||||
|
||||
**Recomendação:** B — reusa contrato URL multi-`municipality`, toggles OR, grupo omnibox “Município (carteira)”, filtro server via união de `municipality.advisors`.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`advisorListUrl.ts`:** `municipalities?: number[]`; parse/serialize `municipality` repetível.
|
||||
- **`advisorListFilters.ts` (novo):** toggle/clear município; `clearAdvisorListFilters`.
|
||||
- **`advisorOmnibox.ts` (novo):** chips, seeds, apply/remove — grupo “Município (carteira)”.
|
||||
- **`advisorData.ts`:** resolver IDs de assessor a partir dos municípios selecionados (OR); short-circuit lista vazia.
|
||||
- **`AdvisorFilters.tsx`:** props `municipalityFilterOptions`; placeholder/label atualizados.
|
||||
- **`assessores/page.tsx`:** montar opções do índice + catalog; `hasQuery` inclui municípios.
|
||||
- **Testes:** parser/href unit; omnibox toggle unit; int `loadAdvisorListPageData` com filtro município.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Opções omnibox: `loadMunicipalityPortfolioIndex` + nome do `municipalityCatalog` (já na página) — busca tipada, sem facet extra.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **URL + server** — parse, href, filtro Payload, testes unit/int.
|
||||
2. **UI** — `AdvisorFilters` + omnibox adapter.
|
||||
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Filtro “sem carteira” / cobertura estadual.
|
||||
- Saved filters / coluna head filter.
|
||||
- Facet dinâmico restrito a municípios com assessor (defer: gatilho se omnibox ficar ruidosa).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Performance:** uma query extra `municipality` só quando há filtro; índice omnibox client-side já cacheado.
|
||||
- **OR vs AND:** toggles multi-valor + `id in union(advisors)` — alinhado a lideranças.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto
|
||||
- [x] Invariantes AGENTS/engineering-standards
|
||||
- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam
|
||||
|
||||
## Débitos deferidos (simplify)
|
||||
|
||||
- Facet server-side restrito a municípios com assessor — gatilho: omnibox ruidosa com 435 seeds.
|
||||
- Toggle municipality via parse round-trip — gatilho: segunda dimensão de filtro em assessores.
|
||||
- Omnibox `municipality:` slice duplicado em apply/remove — gatilho: refactor cross-list omnibox.
|
||||
|
|
@ -11,6 +11,8 @@ import {
|
|||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { hasAdvisorListActiveFilters } from '@/utilities/advisor/advisorListFilters'
|
||||
import {
|
||||
advisorListHrefForPage,
|
||||
loadAdvisorListPageData,
|
||||
|
|
@ -46,16 +48,23 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
|
|||
loadMunicipalityPortfolioIndex(),
|
||||
])
|
||||
|
||||
const municipalityFilterOptions = municipalityIndex.map((entry) => ({
|
||||
value: String(entry.id),
|
||||
label: getMunicipalityCatalogEntry(entry.slug)?.name ?? entry.slug,
|
||||
}))
|
||||
|
||||
const hasActiveFilters = hasAdvisorListActiveFilters(state)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<CampaignListPendingBoundary>
|
||||
<AdvisorFilters state={state} />
|
||||
<AdvisorFilters state={state} municipalityFilterOptions={municipalityFilterOptions} />
|
||||
|
||||
<CampaignListResults>
|
||||
<AdvisorsTable
|
||||
rows={rows}
|
||||
municipalityIndex={municipalityIndex}
|
||||
hasQuery={Boolean(state.q)}
|
||||
hasQuery={hasActiveFilters}
|
||||
updateProfileAction={updateAdvisorProfileFormAction}
|
||||
municipalitiesAction={setAdvisorMunicipalitiesFormAction}
|
||||
createAction={createAdvisorFormAction}
|
||||
|
|
|
|||
|
|
@ -4,30 +4,56 @@ 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 type { AdvisorFilterOption } from '@/utilities/advisor/advisorListFilters'
|
||||
import { advisorListHrefForPage, type AdvisorListState } from '@/utilities/advisor/advisorListUrl'
|
||||
import {
|
||||
applyAdvisorOmniboxSuggestion,
|
||||
buildAdvisorOmniboxChips,
|
||||
buildAdvisorOmniboxSuggestionSeeds,
|
||||
clearAdvisorOmnibox,
|
||||
filterAdvisorOmniboxSuggestions,
|
||||
removeAdvisorOmniboxChip,
|
||||
type AdvisorOmniboxAction,
|
||||
} from '@/utilities/advisor/advisorOmnibox'
|
||||
|
||||
export const AdvisorFilters = ({ state }: { state: AdvisorListState }) => {
|
||||
export const AdvisorFilters = ({
|
||||
state,
|
||||
municipalityFilterOptions,
|
||||
}: {
|
||||
state: AdvisorListState
|
||||
municipalityFilterOptions: AdvisorFilterOption[]
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => advisorListHrefForPage(next, 1),
|
||||
})
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const withPageReset = (next: AdvisorListState): AdvisorListState => ({ ...next, page: 1 })
|
||||
const municipalityLabelsById = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const option of municipalityFilterOptions) {
|
||||
const id = Number(option.value)
|
||||
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
|
||||
}
|
||||
return map
|
||||
}, [municipalityFilterOptions])
|
||||
|
||||
const chips = useMemo(() => buildSearchOnlyOmniboxChips(state), [state])
|
||||
const chips = useMemo(
|
||||
() => buildAdvisorOmniboxChips({ state, municipalityLabelsById }),
|
||||
[state, municipalityLabelsById],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(() => buildSearchOnlyOmniboxSuggestions(query), [query])
|
||||
const suggestionSeeds = useMemo(
|
||||
() => buildAdvisorOmniboxSuggestionSeeds({ municipalityFilterOptions }),
|
||||
[municipalityFilterOptions],
|
||||
)
|
||||
|
||||
const runAction = (action: SearchOnlyOmniboxAction<AdvisorListState>) => {
|
||||
const suggestions = useMemo(
|
||||
() => filterAdvisorOmniboxSuggestions(suggestionSeeds, query),
|
||||
[suggestionSeeds, query],
|
||||
)
|
||||
|
||||
const runAction = (action: AdvisorOmniboxAction) => {
|
||||
if (action.kind === 'clear') {
|
||||
setQuery('')
|
||||
navigate(action.state)
|
||||
|
|
@ -45,41 +71,24 @@ export const AdvisorFilters = ({ state }: { state: AdvisorListState }) => {
|
|||
>
|
||||
<CampaignListOmnibox
|
||||
id="advisor-omnibox"
|
||||
label="Buscar assessor por nome ou e-mail"
|
||||
placeholder="Digite para buscar por nome ou e-mail…"
|
||||
label="Filtrar assessores"
|
||||
placeholder="Digite para buscar por nome, e-mail ou município da carteira…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(
|
||||
applySearchOnlyOmniboxSuggestion({
|
||||
state,
|
||||
suggestionId,
|
||||
withPageReset,
|
||||
}),
|
||||
)
|
||||
runAction(applyAdvisorOmniboxSuggestion({ state, suggestionId }))
|
||||
}}
|
||||
onCommitQuery={(text) => {
|
||||
runAction(
|
||||
applySearchOnlyOmniboxSuggestion({
|
||||
state,
|
||||
suggestionId: `q:${text}`,
|
||||
withPageReset,
|
||||
}),
|
||||
)
|
||||
runAction(applyAdvisorOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeSearchOnlyOmniboxChip({ state, chipId, withPageReset }))
|
||||
runAction(removeAdvisorOmniboxChip({ state, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(
|
||||
clearSearchOnlyOmnibox({
|
||||
state,
|
||||
cleared: { page: 1 },
|
||||
}),
|
||||
)
|
||||
runAction(clearAdvisorOmnibox(state))
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Degenerate omnibox for lists that only expose free-text search (B128:
|
||||
* organizações, assessores). Pure / client-safe.
|
||||
* organizações). Pure / client-safe.
|
||||
*/
|
||||
import {
|
||||
filterOmniboxSuggestionSeeds,
|
||||
|
|
|
|||
46
src/utilities/advisor/advisorListFilters.ts
Normal file
46
src/utilities/advisor/advisorListFilters.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Advisor list filter affordances: município (carteira) toggles and clear.
|
||||
*/
|
||||
import {
|
||||
advisorListStateToRawParams,
|
||||
parseAdvisorListParams,
|
||||
type AdvisorListState,
|
||||
} from '@/utilities/advisor/advisorListUrl'
|
||||
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
|
||||
|
||||
export type AdvisorFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const setAdvisorMunicipalityFilterValues = (
|
||||
state: AdvisorListState,
|
||||
values: string[],
|
||||
): AdvisorListState =>
|
||||
parseAdvisorListParams({
|
||||
...advisorListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
municipality: values,
|
||||
})
|
||||
|
||||
const getAdvisorMunicipalityFilterValues = (state: AdvisorListState): string[] =>
|
||||
(state.municipalities ?? []).map(String)
|
||||
|
||||
export const toggleAdvisorMunicipalityFilter = (
|
||||
state: AdvisorListState,
|
||||
value: string,
|
||||
): AdvisorListState => {
|
||||
if (strictDecimalInteger(value) === undefined) return state
|
||||
const current = getAdvisorMunicipalityFilterValues(state)
|
||||
return setAdvisorMunicipalityFilterValues(
|
||||
state,
|
||||
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop every filter and the search; keep pagination at page 1. */
|
||||
export const clearAdvisorListFilters = (): AdvisorListState => ({
|
||||
page: 1,
|
||||
})
|
||||
|
||||
export const hasAdvisorListActiveFilters = (state: AdvisorListState): boolean =>
|
||||
Boolean(state.q || state.municipalities?.length)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths'
|
||||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
|
|
@ -10,21 +11,40 @@ import {
|
|||
export type AdvisorListState = {
|
||||
page: number
|
||||
q?: string
|
||||
municipalities?: number[]
|
||||
}
|
||||
|
||||
export type AdvisorListSearchParams = RawSearchParams
|
||||
|
||||
export const advisorListStateToRawParams = (
|
||||
state: AdvisorListState,
|
||||
page = state.page,
|
||||
): AdvisorListSearchParams => ({
|
||||
page: String(page),
|
||||
q: state.q,
|
||||
municipality: state.municipalities?.map(String),
|
||||
})
|
||||
|
||||
export const parseAdvisorListParams = (searchParams: RawSearchParams): AdvisorListState => {
|
||||
const q = normalizedText(firstValue(searchParams.q))
|
||||
const page = strictDecimalInteger(firstValue(searchParams.page)) ?? 1
|
||||
const municipalities = allParamValues(searchParams.municipality)
|
||||
.map((token) => strictDecimalInteger(token))
|
||||
.filter((id): id is number => typeof id === 'number' && id > 0)
|
||||
|
||||
return {
|
||||
page,
|
||||
...(q ? { q } : {}),
|
||||
...(municipalities.length ? { municipalities } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const buildAdvisorListSearchParams = (state: AdvisorListState, page = state.page) => {
|
||||
const params = new URLSearchParams()
|
||||
if (state.q) params.set('q', state.q)
|
||||
for (const municipality of state.municipalities ?? []) {
|
||||
params.append('municipality', String(municipality))
|
||||
}
|
||||
if (page > 1) params.set('page', String(page))
|
||||
return params
|
||||
}
|
||||
|
|
|
|||
121
src/utilities/advisor/advisorOmnibox.ts
Normal file
121
src/utilities/advisor/advisorOmnibox.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Advisor list omnibox adapter (B142). Pure / client-safe.
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import {
|
||||
clearAdvisorListFilters,
|
||||
toggleAdvisorMunicipalityFilter,
|
||||
type AdvisorFilterOption,
|
||||
} from '@/utilities/advisor/advisorListFilters'
|
||||
import type { AdvisorListState } from '@/utilities/advisor/advisorListUrl'
|
||||
|
||||
export type AdvisorOmniboxAction =
|
||||
| { kind: 'url'; state: AdvisorListState }
|
||||
| { kind: 'clear'; state: AdvisorListState }
|
||||
|
||||
const MUNICIPALITY_GROUP = 'Município (carteira)'
|
||||
|
||||
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
|
||||
|
||||
const withPageReset = (state: AdvisorListState): AdvisorListState => ({ ...state, page: 1 })
|
||||
|
||||
export const buildAdvisorOmniboxChips = ({
|
||||
state,
|
||||
municipalityLabelsById,
|
||||
}: {
|
||||
state: AdvisorListState
|
||||
municipalityLabelsById: ReadonlyMap<number, string>
|
||||
}): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
|
||||
|
||||
for (const municipalityId of state.municipalities ?? []) {
|
||||
chips.push({
|
||||
id: `municipality:${municipalityId}`,
|
||||
label: chipLabel(
|
||||
MUNICIPALITY_GROUP,
|
||||
municipalityLabelsById.get(municipalityId) ?? `Município #${municipalityId}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
export const buildAdvisorOmniboxSuggestionSeeds = ({
|
||||
municipalityFilterOptions,
|
||||
}: {
|
||||
municipalityFilterOptions: readonly AdvisorFilterOption[]
|
||||
}) => {
|
||||
const seeds = []
|
||||
|
||||
for (const option of municipalityFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `municipality:${option.value}`,
|
||||
group: MUNICIPALITY_GROUP,
|
||||
label: option.label,
|
||||
keywords: ['municipio', 'carteira'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
export const filterAdvisorOmniboxSuggestions = (
|
||||
seeds: ReturnType<typeof buildAdvisorOmniboxSuggestionSeeds>,
|
||||
query: string,
|
||||
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
|
||||
|
||||
export const applyAdvisorOmniboxSuggestion = ({
|
||||
state,
|
||||
suggestionId,
|
||||
}: {
|
||||
state: AdvisorListState
|
||||
suggestionId: string
|
||||
}): AdvisorOmniboxAction => {
|
||||
if (suggestionId.startsWith('q:')) {
|
||||
const q = suggestionId.slice(2)
|
||||
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('municipality:')) {
|
||||
return {
|
||||
kind: 'url',
|
||||
state: toggleAdvisorMunicipalityFilter(state, suggestionId.slice(13)),
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const removeAdvisorOmniboxChip = ({
|
||||
state,
|
||||
chipId,
|
||||
}: {
|
||||
state: AdvisorListState
|
||||
chipId: string
|
||||
}): AdvisorOmniboxAction => {
|
||||
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
|
||||
|
||||
if (chipId.startsWith('municipality:')) {
|
||||
return {
|
||||
kind: 'url',
|
||||
state: toggleAdvisorMunicipalityFilter(state, chipId.slice(13)),
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const clearAdvisorOmnibox = (_state: AdvisorListState): AdvisorOmniboxAction => ({
|
||||
kind: 'clear',
|
||||
state: clearAdvisorListFilters(),
|
||||
})
|
||||
|
|
@ -40,6 +40,36 @@ export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
|
|||
|
||||
const advisorPageSize = 25
|
||||
|
||||
/** Union of advisors linked on any of the given municipalities (OR semantics). */
|
||||
const advisorIdsForMunicipalityFilter = async (
|
||||
payload: Payload,
|
||||
municipalityIds: number[],
|
||||
): Promise<number[]> => {
|
||||
if (municipalityIds.length === 0) return []
|
||||
|
||||
// Intentional admin bypass: same rationale as `municipalityIdsByAdvisorIds` — the
|
||||
// unrestricted route gate already asserted staff; this read only resolves filter ids.
|
||||
const municipalities = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { id: { in: municipalityIds } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { advisors: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const advisorIds = new Set<number>()
|
||||
for (const municipality of municipalities.docs) {
|
||||
for (const advisor of municipality.advisors ?? []) {
|
||||
const id = relationshipId(advisor)
|
||||
if (id !== null) advisorIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return [...advisorIds]
|
||||
}
|
||||
|
||||
/** Fan a município out to each of the advisors the caller asked about. */
|
||||
const collectByAdvisor = <T>(
|
||||
advisors: readonly unknown[] | null | undefined,
|
||||
|
|
@ -133,11 +163,20 @@ export const loadAdvisorListPageData = async (
|
|||
payload: Payload,
|
||||
state: AdvisorListState,
|
||||
): Promise<{ rows: AdvisorRowViewModel[]; totalDocs: number; totalPages: number }> => {
|
||||
const municipalityAdvisorIds = state.municipalities?.length
|
||||
? await advisorIdsForMunicipalityFilter(payload, state.municipalities)
|
||||
: undefined
|
||||
|
||||
if (municipalityAdvisorIds && municipalityAdvisorIds.length === 0) {
|
||||
return { rows: [], totalDocs: 0, totalPages: 0 }
|
||||
}
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
and: [
|
||||
{ role: { equals: 'advisor' } },
|
||||
...(municipalityAdvisorIds ? [{ id: { in: municipalityAdvisorIds } }] : []),
|
||||
...(state.q
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -543,4 +543,34 @@ describe('loadAdvisorListPageData', () => {
|
|||
// Ids only: the list's chips resolve their labels from the static catalog.
|
||||
expect(result.rows[0]!.municipalityIDs).toEqual([municipality.id])
|
||||
})
|
||||
|
||||
it('narrows by municipality portfolio with OR semantics', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisorA = await fixtures.createCampaignUser('advisor')
|
||||
const advisorB = await fixtures.createCampaignUser('advisor')
|
||||
const municipalityA = await fixtures.getMunicipality()
|
||||
const municipalityB = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipalityA.id, [advisorA.id])
|
||||
await fixtures.assignMunicipalityAdvisors(municipalityB.id, [advisorB.id])
|
||||
|
||||
const onlyA = await loadAdvisorListPageData(payload, {
|
||||
page: 1,
|
||||
municipalities: [municipalityA.id],
|
||||
})
|
||||
expect(onlyA.totalDocs).toBe(1)
|
||||
expect(onlyA.rows[0]?.id).toBe(advisorA.id)
|
||||
|
||||
const either = await loadAdvisorListPageData(payload, {
|
||||
page: 1,
|
||||
municipalities: [municipalityA.id, municipalityB.id],
|
||||
})
|
||||
expect(either.totalDocs).toBe(2)
|
||||
expect(either.rows.map((row) => row.id).sort()).toEqual([advisorA.id, advisorB.id].sort())
|
||||
|
||||
const none = await loadAdvisorListPageData(payload, {
|
||||
page: 1,
|
||||
municipalities: [999_999],
|
||||
})
|
||||
expect(none.totalDocs).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ describe.each(qAndPageParsers)('%s list parser (q + page)', (_name, parse) => {
|
|||
expect(parse({ q: ' ' }).q).toBeUndefined()
|
||||
expect(parse({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
|
||||
})
|
||||
|
||||
it('parses repeatable municipality ids and drops invalid tokens', () => {
|
||||
expect(parseAdvisorListParams({ municipality: ['12', 'bad', '0'] }).municipalities).toEqual([
|
||||
12,
|
||||
])
|
||||
expect(parseAdvisorListParams({ municipality: ['3', '7'] }).municipalities).toEqual([3, 7])
|
||||
expect(parseAdvisorListParams({}).municipalities).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOrganizationListParams', () => {
|
||||
|
|
@ -73,4 +81,13 @@ describe('advisorListHrefForPage', () => {
|
|||
'/campanha/assessores?q=edi&page=3',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes repeatable municipality filters', () => {
|
||||
expect(advisorListHrefForPage({ page: 1, municipalities: [4, 9] }, 1)).toBe(
|
||||
'/campanha/assessores?municipality=4&municipality=9',
|
||||
)
|
||||
expect(advisorListHrefForPage({ page: 1, q: 'ana', municipalities: [2] }, 2)).toBe(
|
||||
'/campanha/assessores?q=ana&municipality=2&page=2',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import {
|
|||
clearActivityOmnibox,
|
||||
} from '@/utilities/activityOmnibox'
|
||||
import { parseActivityListParams } from '@/utilities/activityUi'
|
||||
import { parseAdvisorListParams } from '@/utilities/advisor/advisorListUrl'
|
||||
import {
|
||||
applyAdvisorOmniboxSuggestion,
|
||||
buildAdvisorOmniboxChips,
|
||||
buildAdvisorOmniboxSuggestionSeeds,
|
||||
filterAdvisorOmniboxSuggestions,
|
||||
} from '@/utilities/advisor/advisorOmnibox'
|
||||
import { parseDemandListParams } from '@/utilities/demand/demandListUrl'
|
||||
import {
|
||||
applyDemandOmniboxSuggestion,
|
||||
|
|
@ -258,4 +265,28 @@ describe('list omnibox adapters (B128)', () => {
|
|||
})
|
||||
expect(applied).toEqual({ kind: 'url', state: { page: 1, q: 'ana' } })
|
||||
})
|
||||
|
||||
it('advisor toggles municipality portfolio filter and suggests by name', () => {
|
||||
const base = parseAdvisorListParams({})
|
||||
const applied = applyAdvisorOmniboxSuggestion({
|
||||
state: base,
|
||||
suggestionId: 'municipality:42',
|
||||
})
|
||||
expect(applied.kind).toBe('url')
|
||||
if (applied.kind === 'url') {
|
||||
expect(applied.state.municipalities).toEqual([42])
|
||||
}
|
||||
|
||||
const chips = buildAdvisorOmniboxChips({
|
||||
state: parseAdvisorListParams({ municipality: '42' }),
|
||||
municipalityLabelsById: new Map([[42, 'Feira de Santana']]),
|
||||
})
|
||||
expect(chips[0]?.label).toContain('Feira de Santana')
|
||||
|
||||
const seeds = buildAdvisorOmniboxSuggestionSeeds({
|
||||
municipalityFilterOptions: [{ value: '42', label: 'Feira de Santana' }],
|
||||
})
|
||||
const suggestions = filterAdvisorOmniboxSuggestions(seeds, 'feira')
|
||||
expect(suggestions.some((entry) => entry.group === 'Município (carteira)')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue