mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
d0cd103b90
commit
e8f21a9885
8 changed files with 241 additions and 36 deletions
64
docs/plans/demandas-omnibox-tipo-busca-impl.md
Normal file
64
docs/plans/demandas-omnibox-tipo-busca-impl.md
Normal 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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue