B140 — Demandas omnibox: tipo e busca na lista

- Add q param to demand list URL contract and buildDemandListWhere
- Extend demandOmnibox with kind + search chips/suggestions/actions
- Search title and leadership contact name (visible list columns)
- clearDemandOmnibox drops bar filters but keeps activity deep-link
- Unit + int tests; impl plan docs/plans/demandas-omnibox-tipo-busca-impl.md

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:03:24 +00:00
parent d0cd103b90
commit e8f21a9885
No known key found for this signature in database
8 changed files with 241 additions and 36 deletions

View file

@ -0,0 +1,64 @@
# Impl: Demandas — tipo e busca na omnibox
Status: aprovado
Atualizado em: 2026-08-02
Issue: #308
Intenção: docs/plans/demandas-omnibox-tipo-busca.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** Omnibox de `/campanha/demandas` expõe Tipo + Busca junto do Status; chips coexistentes e removíveis; ausência = sem restrição.
- **O que NÃO negociar:** deep-link `activity` continua URL-only (fora da omnibox); leader lockdown; sem segunda toolbar; busca só em campos visíveis na lista.
- **O que reavaliar:** B128 preservava `kind` no `clear` porque era deep-link — com tipo na omnibox, `clear` zera kind também; mantém só `activityId`.
## Abordagem recomendada
```mermaid
flowchart LR
URL[demandListUrl q/kind/status] --> Omnibox[demandOmnibox]
Omnibox --> UI[DemandFilters]
URL --> Where[buildDemandListWhere]
Where --> Loader[loadDemandListPageData]
```
**Opções:** A) estender adapter B128 + `q` na URL · B) busca só client-side · C) novo componente de filtro
**Recomendação:** A — precedente território (busca) + atividade (tipo); URL canônica B18.
**Rejeitadas:** B (paginação mentirosa); C (twin).
### Componentes / mudanças
- **`demandListUrl.ts`:** `q?: string`; parse/serialize; `buildDemandListWhere(state)`.
- **`demandOmnibox.ts`:** seeds/chips/ações para `kind` e `q`; `clear` sem `kind`, com `activityId`.
- **`campaignDemandData.ts`:** usar `buildDemandListWhere`.
- **`DemandFilters.tsx`:** copy omnibox (label/placeholder).
- **Tests:** unit adapters + parser `q`; int `loadDemandListPageData` com `q` em título e solicitante.
### Migration / access
Sem migration. Access inalterado (`overrideAccess: false`).
## Fases verificáveis
1. URL + where + omnibox adapter + unit tests
2. UI copy + int test busca
3. `pnpm gate:fast` → `pnpm push`
## Rabbit holes / Não escopo
- Busca em description/anexos/histórico
- Filtro omnibox por `activity`
- Saved filters
## Riscos e mitigação
- **`leadership.contact.name` no where:** precedente lideranças/apoiadores; int test cobre solicitante.
- **`clear` quebrava expectativa de kind deep-link:** produto agora pede tipo na barra — `clear` alinha com atividades.
## 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)
**Decision-quality:** 5/5

View file

@ -50,8 +50,8 @@ export const DemandFilters = ({ state }: { state: DemandListState }) => {
>
<CampaignListOmnibox
id="demand-omnibox"
label="Filtrar demandas por status"
placeholder="Digite para filtrar por status…"
label="Filtrar demandas"
placeholder="Digite para buscar ou filtrar por tipo e status…"
chips={chips}
suggestions={suggestions}
query={query}

View file

@ -8,6 +8,7 @@ import type { Activity, CampaignDemand, CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildDemandListHref,
buildDemandListWhere,
parseDemandListParams,
type DemandListState,
} from '@/utilities/demand/demandListUrl'
@ -103,13 +104,7 @@ export const loadDemandListPageData = async (
const [result, openCount] = await Promise.all([
payload.find({
collection: 'campaignDemand',
where: {
and: [
...(state.status ? [{ status: { equals: state.status } }] : []),
...(state.kind ? [{ kind: { equals: state.kind } }] : []),
...(state.activityId ? [{ activity: { equals: state.activityId } }] : []),
],
},
where: buildDemandListWhere(state),
depth: 0,
limit: demandPageSize,
page: state.page,

View file

@ -1,14 +1,18 @@
import type { Where } from 'payload'
import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export type DemandListState = {
page: number
q?: string
status?: CampaignDemandStatus
kind?: CampaignDemandKind
activityId?: number
@ -18,9 +22,11 @@ export const parseDemandListParams = (searchParams: RawSearchParams): DemandList
const rawStatus = firstValue(searchParams.status)
const rawKind = firstValue(searchParams.kind)
const activityId = strictDecimalInteger(firstValue(searchParams.activity))
const q = normalizedText(firstValue(searchParams.q))
return {
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(q ? { q } : {}),
...(campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)
? { status: rawStatus as CampaignDemandStatus }
: {}),
@ -31,11 +37,28 @@ export const parseDemandListParams = (searchParams: RawSearchParams): DemandList
}
}
export const buildDemandListWhere = (state: DemandListState): Where => {
const filters: Where[] = []
if (state.status) filters.push({ status: { equals: state.status } })
if (state.kind) filters.push({ kind: { equals: state.kind } })
if (state.activityId) filters.push({ activity: { equals: state.activityId } })
if (state.q) {
filters.push({
or: [{ title: { contains: state.q } }, { 'leadership.contact.name': { contains: state.q } }],
})
}
return filters.length ? { and: filters } : {}
}
const buildDemandListSearchParams = (
state: DemandListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
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))

View file

@ -1,6 +1,6 @@
/**
* Demand list omnibox adapter (B128). Status-only; kind/activity deep-links
* stay URL-only without new list UI. Pure / client-safe.
* Demand list omnibox adapter (B128 + B140). Status, kind and free-text search;
* activity deep-links stay URL-only without list UI. Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
@ -9,8 +9,11 @@ import {
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
campaignDemandKindLabels,
campaignDemandKinds,
campaignDemandStatusLabels,
campaignDemandStatuses,
type CampaignDemandKind,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import type { DemandListState } from '@/utilities/demand/demandListUrl'
@ -26,27 +29,60 @@ const chipLabel = (dimension: string, value: string): string => `${dimension}: $
const withPageReset = (state: DemandListState): DemandListState => ({ ...state, page: 1 })
export const buildDemandOmniboxChips = (state: DemandListState): CampaignListOmniboxChip[] => {
if (!state.status) return []
return [
{
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
if (state.kind) {
chips.push({
id: `kind:${state.kind}`,
label: chipLabel('Tipo', campaignDemandKindLabels[state.kind]),
})
}
if (state.status) {
chips.push({
id: `status:${state.status}`,
label: chipLabel('Status', campaignDemandStatusLabels[state.status]),
},
]
})
}
return chips
}
export const buildDemandOmniboxSuggestionSeeds = () =>
campaignDemandStatuses.map((status) =>
createOmniboxSuggestionSeed(
{
id: `status:${status}`,
group: 'Status',
label: campaignDemandStatusLabels[status],
keywords: ['status', 'situacao'],
},
{ emptyQueryVisible: true },
),
)
export const buildDemandOmniboxSuggestionSeeds = () => {
const seeds = []
for (const kind of campaignDemandKinds) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `kind:${kind}`,
group: 'Tipo',
label: campaignDemandKindLabels[kind],
keywords: ['tipo', 'demanda'],
},
{ emptyQueryVisible: true },
),
)
}
for (const status of campaignDemandStatuses) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `status:${status}`,
group: 'Status',
label: campaignDemandStatusLabels[status],
keywords: ['status', 'situacao'],
},
{ emptyQueryVisible: true },
),
)
}
return seeds
}
export const filterDemandOmniboxSuggestions = (
seeds: ReturnType<typeof buildDemandOmniboxSuggestionSeeds>,
@ -60,6 +96,26 @@ export const applyDemandOmniboxSuggestion = ({
state: DemandListState
suggestionId: string
}): DemandOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (suggestionId.startsWith('kind:')) {
const value = suggestionId.slice(5)
if (!campaignDemandKinds.includes(value as CampaignDemandKind)) {
return { kind: 'url', state }
}
const kind = value as CampaignDemandKind
return {
kind: 'url',
state: withPageReset({
...state,
kind: state.kind === kind ? undefined : kind,
}),
}
}
if (!suggestionId.startsWith('status:')) return { kind: 'url', state }
const value = suggestionId.slice(7)
@ -84,6 +140,12 @@ export const removeDemandOmniboxChip = ({
state: DemandListState
chipId: string
}): DemandOmniboxAction => {
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
}
if (chipId.startsWith('kind:')) {
return { kind: 'url', state: withPageReset({ ...state, kind: undefined }) }
}
if (chipId.startsWith('status:')) {
return { kind: 'url', state: withPageReset({ ...state, status: undefined }) }
}
@ -94,7 +156,6 @@ export const clearDemandOmnibox = (state: DemandListState): DemandOmniboxAction
kind: 'clear',
state: withPageReset({
page: 1,
...(state.kind ? { kind: state.kind } : {}),
...(state.activityId ? { activityId: state.activityId } : {}),
}),
})

View file

@ -445,6 +445,47 @@ describe('loadDemandListPageData', () => {
await expect(loadDemandListPageData(payload, leader, { page: 1 })).rejects.toThrow(/permissão/i)
})
it('narrows by free-text search on title and requester name', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const marker = fixtures.marker('demand-q')
const contact = await fixtures.createContact({ name: `${marker} solicitante` })
const leadership = await fixtures.createLeadership({
contact: contact.id,
municipalities: [municipality.id],
})
const byTitle = await fixtures.createCampaignDemand({
municipality: municipality.id,
title: `${marker} banner comitê`,
})
const byRequester = await fixtures.createCampaignDemand({
municipality: municipality.id,
leadership: leadership.id,
title: `${marker} outra demanda`,
})
await fixtures.createCampaignDemand({
municipality: municipality.id,
title: `${marker} sem match`,
})
const titleHits = await loadDemandListPageData(payload, coordinator, {
page: 1,
q: 'banner comitê',
})
expect(titleHits.rows.some((row) => row.slug === byTitle.slug)).toBe(true)
expect(titleHits.rows.some((row) => row.slug === byRequester.slug)).toBe(false)
const requesterHits = await loadDemandListPageData(payload, coordinator, {
page: 1,
q: 'solicitante',
})
expect(requesterHits.rows.some((row) => row.slug === byRequester.slug)).toBe(true)
expect(requesterHits.rows.some((row) => row.slug === byTitle.slug)).toBe(false)
})
})
describe('loadAdvisorListPageData', () => {

View file

@ -56,6 +56,11 @@ describe('parseDemandListParams', () => {
expect(parseDemandListParams({ kind: 'outra-coisa' }).kind).toBeUndefined()
})
it('parses trimmed search query', () => {
expect(parseDemandListParams({ q: ' banner ' }).q).toBe('banner')
expect(parseDemandListParams({ q: ' ' }).q).toBeUndefined()
})
it('defaults to page 1 with no filters', () => {
expect(parseDemandListParams({})).toEqual({ page: 1 })
})

View file

@ -121,17 +121,33 @@ describe('list omnibox adapters (B128)', () => {
expect(applied.kind).toBe('url')
})
it('demand toggles exclusive status and preserves deep-link fields on clear', () => {
const state = parseDemandListParams({ status: 'aberta', activity: '42' })
it('demand toggles exclusive status, kind and search; preserves activity on clear', () => {
const state = parseDemandListParams({
status: 'aberta',
kind: 'material',
q: 'banner',
activity: '42',
})
const chips = buildDemandOmniboxChips(state)
expect(chips[0]?.label).toContain('Aberta')
expect(chips.map((chip) => chip.id)).toEqual(['q', 'kind:material', 'status:aberta'])
const toggled = applyDemandOmniboxSuggestion({ state, suggestionId: 'status:aberta' })
expect(toggled.kind).toBe('url')
if (toggled.kind === 'url') expect(toggled.state.status).toBeUndefined()
const toggledStatus = applyDemandOmniboxSuggestion({ state, suggestionId: 'status:aberta' })
expect(toggledStatus.kind).toBe('url')
if (toggledStatus.kind === 'url') expect(toggledStatus.state.status).toBeUndefined()
const toggledKind = applyDemandOmniboxSuggestion({ state, suggestionId: 'kind:transporte' })
if (toggledKind.kind === 'url') expect(toggledKind.state.kind).toBe('transporte')
const search = applyDemandOmniboxSuggestion({ state, suggestionId: 'q:comitê' })
if (search.kind === 'url') expect(search.state.q).toBe('comitê')
const cleared = clearDemandOmnibox(state)
if (cleared.kind === 'clear') expect(cleared.state.activityId).toBe(42)
if (cleared.kind === 'clear') {
expect(cleared.state.activityId).toBe(42)
expect(cleared.state.kind).toBeUndefined()
expect(cleared.state.status).toBeUndefined()
expect(cleared.state.q).toBeUndefined()
}
})
it('search-only omnibox commits q', () => {