diff --git a/docs/plans/atividades-omnibox-presets-busca-impl.md b/docs/plans/atividades-omnibox-presets-busca-impl.md new file mode 100644 index 00000000..1acf9343 --- /dev/null +++ b/docs/plans/atividades-omnibox-presets-busca-impl.md @@ -0,0 +1,67 @@ +# Impl: Atividades — presets (Próximos/…) e busca na omnibox; remover abas (B138) + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #306 +Intenção: docs/plans/atividades-omnibox-presets-busca.md +Appetite restante: herdado (~0,5–1 dia eng) + +## Leitura da intenção + +- **Outcome:** Uma barra omnibox na lista de atividades — presets de janela (Próximos/Todos/Realizados/Rascunhos) e busca por texto como chips/sugestões; seletor de abas acima some; default Próximos intacto. +- **O que NÃO negociar:** semântica de Próximos (planejado/confirmado + startAt ≥ agora); leader lockdown inalterado; deep-links `?tab=` preservados; tipo/município/status (em Todos) na omnibox. +- **O que reavaliar:** hipótese de mover preset para dentro da omnibox — confirmada; não precisa de mudança no chassis `CampaignListOmnibox`. + +## Abordagem recomendada + +```mermaid +flowchart LR + URL["URL ?tab=&q=&kind=&…"] --> parse["parseActivityListParams"] + parse --> where["buildActivityListWhere"] + parse --> omnibox["activityOmnibox adapter"] + omnibox --> UI["ActivityFilters → CampaignListOmnibox"] +``` + +**Opções consideradas:** + +- A) Tab como chip de apresentação (não-URL, como scenario em municípios) +- B) Tab permanece em `?tab=` com chip/sugestões na omnibox +- C) Tab vira filtro de status composto (rejeitado pelo produto) + +**Recomendação:** B — mantém deep-links, reusa `activityUi` existente; só muda superfície. +**Rejeitadas:** A (quebra bookmarks `?tab=realizados`); C (perde modo diário nomeado). + +### Componentes / mudanças + +- **`activityUi.ts`**: adicionar `q` ao estado/URL; `buildActivityListWhere` com OR `title` + `responsible.name`. +- **`activityOmnibox.ts`**: seeds grupo `Janela` (`tab:*`); chip de janela quando `tab !== proximos`; chip `Busca:`; apply/remove/clear para `q` e `tab`. +- **`ActivityFilters.tsx`**: remover `ActivityTabSwitch`; wire `onCommitQuery`; placeholder atualizado. +- **Migration:** sem migration. +- **Access / Consent:** inalterado (staff-only lista). +- **UI:** Impeccable B — espelhar `SupporterFilters` (uma barra, chips, pending boundary existente). + +### Dados → forma + +- Chip de janela mostra `activityTabLabels[tab]` (ex. "Realizados"), não status cru. +- Busca: título + nome do responsável (já no card; precedente `contact.name` em leadership). + +## Fases verificáveis + +1. **Server/URL** — `activityUi` + testes unitários (`q`, where OR). +2. **Omnibox adapter + UI** — `activityOmnibox`, `ActivityFilters`; testes B128 estendidos. +3. **Gates** — `pnpm gate:fast`; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Saved filters B18; intervalo de datas genérico; mudanças em outras listas. +- Novo arquivo `activityOmnibox.unit.spec.ts` — estender `listOmniboxB128` + `activityUi` (evitar twin de test file). + +## Riscos e mitigação + +- **Tab + status em Todos:** ao mudar tab, limpar `status` se sair de `todos` (mesmo comportamento do antigo `buildTabHref`). +- **`q` + tab:** filtros AND — busca restringe dentro da janela ativa. + +## Débitos deferidos (simplify B138) + +- **DRY municipality/setExclusiveField helpers** (score 3, `defer_trigger`): extrair quando 3º domínio omnibox precisar do mesmo padrão — já ledgerado em B128; não bloqueia B138. +- **Int test `responsible.name` contains** (score 3, `defer_trigger`): where JSON coberto em unit; int espelhando supporter quando houver fixture de activity com responsável. diff --git a/src/components/campaign/activity/ActivityFilters.tsx b/src/components/campaign/activity/ActivityFilters.tsx index d2a47f8c..8049065f 100644 --- a/src/components/campaign/activity/ActivityFilters.tsx +++ b/src/components/campaign/activity/ActivityFilters.tsx @@ -1,12 +1,10 @@ 'use client' -import Link from 'next/link' import { useMemo, useState } from 'react' import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' -import { Button } from '@/components/ui/button' import { applyActivityOmniboxSuggestion, buildActivityOmniboxChips, @@ -16,46 +14,7 @@ import { removeActivityOmniboxChip, type ActivityOmniboxAction, } from '@/utilities/activityOmnibox' -import { - activityTabLabels, - activityTabs, - buildActivityListHref, - buildActivityListSearchParams, - type ActivityListState, - type ActivityTab, -} from '@/utilities/activityUi' - -const buildTabHref = (state: ActivityListState, tab: ActivityTab): string => { - const params = buildActivityListSearchParams({ - page: 1, - tab, - kind: state.kind, - status: tab === 'todos' ? state.status : undefined, - municipality: state.municipality, - }) - const query = params.toString() - return query ? `/campanha/atividades?${query}` : '/campanha/atividades' -} - -const ActivityTabSwitch = ({ state }: { state: ActivityListState }) => ( - -) +import { buildActivityListHref, type ActivityListState } from '@/utilities/activityUi' export const ActivityFilters = ({ state, @@ -116,35 +75,34 @@ export const ActivityFilters = ({ } return ( -
- - -
{ - event.preventDefault() + { + event.preventDefault() + }} + > + { + runAction(applyActivityOmniboxSuggestion({ state, suggestionId })) }} - > - { - runAction(applyActivityOmniboxSuggestion({ state, suggestionId })) - }} - onRemoveChip={(chipId) => { - runAction(removeActivityOmniboxChip({ state, chipId })) - }} - onClearAll={() => { - runAction(clearActivityOmnibox(state)) - }} - /> - -
+ onCommitQuery={(text) => { + runAction(applyActivityOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeActivityOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearActivityOmnibox(state)) + }} + /> + ) } diff --git a/src/utilities/activityOmnibox.ts b/src/utilities/activityOmnibox.ts index 071c3a57..daaa1598 100644 --- a/src/utilities/activityOmnibox.ts +++ b/src/utilities/activityOmnibox.ts @@ -1,6 +1,6 @@ /** - * Activity list omnibox adapter (B128). Pure / client-safe. - * Tab preset stays outside the omnibox (product map). + * Activity list omnibox adapter (B128 + B138). Pure / client-safe. + * Window presets and text search live inside the omnibox. */ import { createOmniboxSuggestionSeed, @@ -8,8 +8,11 @@ import { type CampaignListOmniboxChip, type CampaignListOmniboxSuggestion, } from '@/lib/campaignListOmnibox' +import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery' import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity' import { + activityTabLabels, + activityTabs, buildActivityListSearchParams, parseActivityListParams, type ActivityListState, @@ -52,6 +55,24 @@ const setExclusiveField = ( return parseStateFromParams(raw) } +const tabKeywords: Record = { + proximos: ['proximos', 'próximos', 'proximo', 'próximo'], + todos: ['todos', 'todas'], + realizados: ['realizados', 'realizado'], + rascunhos: ['rascunhos', 'rascunho'], +} + +const setTab = (state: ActivityListState, tab: ActivityTab): ActivityListState => { + const params = buildActivityListSearchParams(withPageReset({ ...state, tab })) + const raw: Record = Object.fromEntries(params.entries()) + + if (tab !== 'todos') { + delete raw.status + } + + return parseStateFromParams(raw) +} + export const buildActivityOmniboxChips = ({ state, municipalityLabelsById, @@ -61,6 +82,15 @@ export const buildActivityOmniboxChips = ({ }): CampaignListOmniboxChip[] => { const chips: CampaignListOmniboxChip[] = [] + if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) }) + + if (state.tab !== 'proximos') { + chips.push({ + id: `tab:${state.tab}`, + label: activityTabLabels[state.tab], + }) + } + if (state.kind) { chips.push({ id: `kind:${state.kind}`, @@ -97,6 +127,20 @@ export const buildActivityOmniboxSuggestionSeeds = ({ }) => { const seeds = [] + for (const tabValue of activityTabs) { + seeds.push( + createOmniboxSuggestionSeed( + { + id: `tab:${tabValue}`, + group: 'Janela', + label: activityTabLabels[tabValue], + keywords: ['janela', ...tabKeywords[tabValue]], + }, + { emptyQueryVisible: true }, + ), + ) + } + for (const [value, label] of Object.entries(activityKindLabels)) { seeds.push( createOmniboxSuggestionSeed( @@ -153,6 +197,19 @@ export const applyActivityOmniboxSuggestion = ({ state: ActivityListState suggestionId: string }): ActivityOmniboxAction => { + if (suggestionId.startsWith('q:')) { + const { trimmed } = normalizeContactSearchQuery(suggestionId.slice(2)) + const q = isContactSearchQueryReady(trimmed) ? trimmed : undefined + return { kind: 'url', state: withPageReset({ ...state, q }) } + } + + if (suggestionId.startsWith('tab:')) { + const value = suggestionId.slice(4) as ActivityTab + if (!activityTabs.includes(value)) return { kind: 'url', state } + const next = state.tab === value ? setTab(state, 'proximos') : setTab(state, value) + return { kind: 'url', state: next } + } + if (suggestionId.startsWith('kind:')) { const value = suggestionId.slice(5) const next = @@ -190,6 +247,12 @@ export const removeActivityOmniboxChip = ({ state: ActivityListState chipId: string }): ActivityOmniboxAction => { + if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) } + + if (chipId.startsWith('tab:')) { + return { kind: 'url', state: setTab(state, 'proximos') } + } + if (chipId.startsWith('kind:')) { return { kind: 'url', state: setExclusiveField(state, 'kind', undefined) } } diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index b8dd55ce..a42236fa 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -1,9 +1,11 @@ import type { Where } from 'payload' +import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery' import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity' import { buildListHref, firstValue, + normalizedText, resolveListUrl, strictDecimalInteger, type RawSearchParams as CampaignListRawSearchParams, @@ -26,6 +28,7 @@ export const activityTabLabels: Record = { export type ActivityListState = { page: number tab: ActivityTab + q?: string kind?: ActivityKind status?: ActivityStatus municipality?: number @@ -33,7 +36,7 @@ export type ActivityListState = { type RawSearchParams = CampaignListRawSearchParams -const activityListParamNames = ['tab', 'kind', 'status', 'municipality', 'page'] as const +const activityListParamNames = ['q', 'tab', 'kind', 'status', 'municipality', 'page'] as const const activityListParamNameSet = new Set(activityListParamNames) @@ -46,8 +49,15 @@ const isActivityKind = (value: string | undefined): value is ActivityKind => const isActivityStatus = (value: string | undefined): value is ActivityStatus => activityStatuses.includes(value as ActivityStatus) +const isActivityListSearchReady = (raw: string | undefined): string | undefined => { + if (!raw) return undefined + const { trimmed } = normalizeContactSearchQuery(raw) + return isContactSearchQueryReady(trimmed) ? trimmed : undefined +} + export const parseActivityListParams = (params: RawSearchParams): ActivityListState => { const rawPage = strictDecimalInteger(firstValue(params.page)) + const q = isActivityListSearchReady(normalizedText(firstValue(params.q))) const rawTab = firstValue(params.tab) const tab = isActivityTab(rawTab) ? rawTab : 'proximos' const rawKind = firstValue(params.kind) @@ -59,6 +69,7 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt return { page: rawPage ?? 1, tab, + ...(q ? { q } : {}), ...(kind ? { kind } : {}), ...(status ? { status } : {}), ...(municipality ? { municipality } : {}), @@ -68,6 +79,15 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt export const buildActivityListWhere = (state: ActivityListState, now: Date): Where => { const filters: Where[] = [] + if (state.q) { + const q = isActivityListSearchReady(state.q) + if (q) { + filters.push({ + or: [{ title: { contains: q } }, { 'responsible.name': { contains: q } }], + }) + } + } + if (state.kind) filters.push({ kind: { equals: state.kind } }) if (state.municipality) filters.push({ municipality: { equals: state.municipality } }) @@ -91,6 +111,7 @@ export const buildActivityListSearchParams = ( ): URLSearchParams => { const canonicalState = parseActivityListParams({ page: String(page), + q: state.q, tab: state.tab, kind: state.kind, status: state.status, @@ -98,6 +119,7 @@ export const buildActivityListSearchParams = ( }) const params = new URLSearchParams() + if (canonicalState.q) params.set('q', canonicalState.q) if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab) if (canonicalState.kind) params.set('kind', canonicalState.kind) if (canonicalState.status) params.set('status', canonicalState.status) diff --git a/tests/unit/activityUi.unit.spec.ts b/tests/unit/activityUi.unit.spec.ts index a1b87609..e91cab15 100644 --- a/tests/unit/activityUi.unit.spec.ts +++ b/tests/unit/activityUi.unit.spec.ts @@ -39,6 +39,12 @@ describe('parseActivityListParams', () => { expect(parseActivityListParams({ municipality: '12' }).municipality).toBe(12) expect(parseActivityListParams({ municipality: '0' }).municipality).toBeUndefined() }) + + it('parses and trims q', () => { + expect(parseActivityListParams({ q: ' comício ' }).q).toBe('comício') + expect(parseActivityListParams({ q: ' ' }).q).toBeUndefined() + expect(parseActivityListParams({ q: 'a' }).q).toBeUndefined() + }) }) describe('buildActivityListWhere (tab → where matrix)', () => { @@ -81,6 +87,19 @@ describe('buildActivityListWhere (tab → where matrix)', () => { ], }) }) + + it('filters by title or responsible name when q is set', () => { + const where = buildActivityListWhere({ page: 1, tab: 'proximos', q: 'Maria' }, NOW) + expect(where).toEqual({ + and: [ + { + or: [{ title: { contains: 'Maria' } }, { 'responsible.name': { contains: 'Maria' } }], + }, + { status: { in: ['planejado', 'confirmado'] } }, + { startAt: { greater_than_equal: NOW.toISOString() } }, + ], + }) + }) }) describe('buildActivityListSearchParams', () => { @@ -95,11 +114,14 @@ describe('buildActivityListSearchParams', () => { const params = buildActivityListSearchParams({ page: 1, tab: 'todos', + q: 'comício', kind: 'caminhada', status: 'cancelado', municipality: 9, }) - expect(params.toString()).toBe('tab=todos&kind=caminhada&status=cancelado&municipality=9') + expect(params.toString()).toBe( + 'q=com%C3%ADcio&tab=todos&kind=caminhada&status=cancelado&municipality=9', + ) }) }) diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts index 861318e6..69c4a944 100644 --- a/tests/unit/listOmniboxB128.unit.spec.ts +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -10,7 +10,10 @@ import { import { applyActivityOmniboxSuggestion, buildActivityOmniboxChips, + buildActivityOmniboxSuggestionSeeds, clearActivityOmnibox, + filterActivityOmniboxSuggestions, + removeActivityOmniboxChip, } from '@/utilities/activityOmnibox' import { parseActivityListParams } from '@/utilities/activityUi' import { parseAdvisorListParams } from '@/utilities/advisor/advisorListUrl' @@ -189,6 +192,48 @@ describe('list omnibox adapters (B128)', () => { expect(applied.kind).toBe('url') }) + it('activity applies window preset and search (B138)', () => { + const base = parseActivityListParams({}) + const seeds = buildActivityOmniboxSuggestionSeeds({ tab: base.tab, municipalityOptions: [] }) + const suggestions = filterActivityOmniboxSuggestions(seeds, 'realizados') + expect(suggestions.some((entry) => entry.id === 'tab:realizados')).toBe(true) + + const tabApplied = applyActivityOmniboxSuggestion({ + state: base, + suggestionId: 'tab:realizados', + }) + expect(tabApplied.kind).toBe('url') + if (tabApplied.kind === 'url') expect(tabApplied.state.tab).toBe('realizados') + + const searchApplied = applyActivityOmniboxSuggestion({ + state: parseActivityListParams({ tab: 'todos', status: 'planejado' }), + suggestionId: 'q:comício', + }) + expect(searchApplied.kind).toBe('url') + if (searchApplied.kind === 'url') { + expect(searchApplied.state.q).toBe('comício') + expect(searchApplied.state.status).toBe('planejado') + } + + const leavingTodos = applyActivityOmniboxSuggestion({ + state: parseActivityListParams({ tab: 'todos', status: 'planejado' }), + suggestionId: 'tab:realizados', + }) + if (leavingTodos.kind === 'url') expect(leavingTodos.state.status).toBeUndefined() + + const chips = buildActivityOmniboxChips({ + state: parseActivityListParams({ tab: 'realizados', q: 'Maria' }), + municipalityLabelsById: new Map(), + }) + expect(chips.map((chip) => chip.id)).toEqual(['q', 'tab:realizados']) + + const removedTab = removeActivityOmniboxChip({ + state: parseActivityListParams({ tab: 'realizados' }), + chipId: 'tab:realizados', + }) + if (removedTab.kind === 'url') expect(removedTab.state.tab).toBe('proximos') + }) + it('demand toggles exclusive status, kind and search; preserves activity on clear', () => { const state = parseDemandListParams({ status: 'aberta',