From e8f21a9885d488de8536c0406b9f045bcbccabbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:03:24 +0000 Subject: [PATCH] =?UTF-8?q?B140=20=E2=80=94=20Demandas=20omnibox:=20tipo?= =?UTF-8?q?=20e=20busca=20na=20lista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../plans/demandas-omnibox-tipo-busca-impl.md | 64 +++++++++++ .../campaign/demand/DemandFilters.tsx | 4 +- src/utilities/campaignDemandData.ts | 9 +- src/utilities/demand/demandListUrl.ts | 23 ++++ src/utilities/demand/demandOmnibox.ts | 101 ++++++++++++++---- tests/int/campaignEntityListData.int.spec.ts | 41 +++++++ .../campaignEntityListParsers.unit.spec.ts | 5 + tests/unit/listOmniboxB128.unit.spec.ts | 30 ++++-- 8 files changed, 241 insertions(+), 36 deletions(-) create mode 100644 docs/plans/demandas-omnibox-tipo-busca-impl.md diff --git a/docs/plans/demandas-omnibox-tipo-busca-impl.md b/docs/plans/demandas-omnibox-tipo-busca-impl.md new file mode 100644 index 00000000..797c141d --- /dev/null +++ b/docs/plans/demandas-omnibox-tipo-busca-impl.md @@ -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 diff --git a/src/components/campaign/demand/DemandFilters.tsx b/src/components/campaign/demand/DemandFilters.tsx index f261c903..0240db86 100644 --- a/src/components/campaign/demand/DemandFilters.tsx +++ b/src/components/campaign/demand/DemandFilters.tsx @@ -50,8 +50,8 @@ export const DemandFilters = ({ state }: { state: DemandListState }) => { > { + 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)) diff --git a/src/utilities/demand/demandOmnibox.ts b/src/utilities/demand/demandOmnibox.ts index bf3f1975..c85b7041 100644 --- a/src/utilities/demand/demandOmnibox.ts +++ b/src/utilities/demand/demandOmnibox.ts @@ -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, @@ -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 } : {}), }), }) diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts index bb5fb0a5..96d373b0 100644 --- a/tests/int/campaignEntityListData.int.spec.ts +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -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', () => { diff --git a/tests/unit/campaignEntityListParsers.unit.spec.ts b/tests/unit/campaignEntityListParsers.unit.spec.ts index 7a14d650..40f8e35e 100644 --- a/tests/unit/campaignEntityListParsers.unit.spec.ts +++ b/tests/unit/campaignEntityListParsers.unit.spec.ts @@ -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 }) }) diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts index 02ab4b39..fe04fbda 100644 --- a/tests/unit/listOmniboxB128.unit.spec.ts +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -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', () => {