B139 — tipo na omnibox de organizações

Adapter organizationOmnibox (q + kind exclusivo), OrganizationFilters
migrado do searchOnly degenerado, testes unit B128.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:02:46 +00:00
parent d0cd103b90
commit 68ab54756e
No known key found for this signature in database
4 changed files with 252 additions and 32 deletions

View file

@ -0,0 +1,67 @@
# Impl: Organizações — tipo na omnibox
Status: aprovado
Atualizado em: 2026-08-02
Issue: #307
Intenção: docs/plans/organizacoes-omnibox-tipo.md
Appetite restante: herdado (~0,5 dia)
## Leitura da intenção
- **Outcome:** Staff filtra organizações por **Tipo** na omnibox (sugestões + chip removível), coexistindo com busca por nome; sem chip de tipo = todas do escopo.
- **O que NÃO negociar:** tipos existentes (`organizationKinds`); `kind` único na URL; sem segunda toolbar; sem saved filters; busca `q` intacta.
- **O que reavaliar:** hipótese de “estender `searchOnlyListOmnibox`” — melhor um adapter de domínio dedicado, como `demandOmnibox` / `supporterOmnibox`.
## Abordagem recomendada
```mermaid
flowchart LR
OF[OrganizationFilters] --> OO[organizationOmnibox]
OO --> CLO[campaignListOmnibox]
OF --> OLU[organizationListUrl]
OO --> OKL[organizationKindLabels]
```
**Opções consideradas:**
- A — Estender `searchOnlyListOmnibox` com dimensão genérica
- B — Adapter `organizationOmnibox.ts` no domínio (precedente B128)
- C — Inline na componente `OrganizationFilters`
**Recomendação:** B — espelha `demandOmnibox` + `q` de `supporterOmnibox`; mantém `searchOnlyListOmnibox` degenerado para assessores; testável em unit.
**Rejeitadas:** A (abstração prematura para um único consumidor); C (lógica fora do adapter de domínio).
### Componentes / mudanças
- **`organizationOmnibox.ts`** (`src/utilities/organization/`): chips/sugestões/ações para `q` + `kind`; labels de `organizationKindLabels`; `emptyQueryVisible` nos tipos.
- **`OrganizationFilters.tsx`**: trocar imports de `searchOnlyListOmnibox` pelo adapter; label/placeholder mencionam tipo.
- **Migration:** sem migration.
- **Access / Consent:** inalterado (filtro de lista staff existente).
- **UI:** Impeccable B — encaixe na lista; reuso `CampaignListOmnibox` + `useCampaignListFilterNavigation`.
### Dados → forma
- N/A (só filtro de lista).
## Fases verificáveis
1. **Adapter + testes unit** — `organizationOmnibox.ts` + caso em `listOmniboxB128.unit.spec.ts`
2. **UI** — `OrganizationFilters.tsx`
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`
## Rabbit holes / Não escopo (engenharia)
- Multi-tipo OR na URL
- Filtro por município / lideranças
- Saved filters
## Riscos e mitigação
- **Contrato URL `q`+`kind`:** adapter só lê/escreve `OrganizationListState` via `organizationListUrl`; testes cobrem toggle e chips.
- **Clear all:** passa a zerar `q` e `kind` (chip de tipo agora é affordance da omnibox, não deep-link oculto).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit em `listOmniboxB128.unit.spec.ts`)

View file

@ -5,13 +5,14 @@ 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'
applyOrganizationOmniboxSuggestion,
buildOrganizationOmniboxChips,
buildOrganizationOmniboxSuggestionSeeds,
clearOrganizationOmnibox,
filterOrganizationOmniboxSuggestions,
removeOrganizationOmniboxChip,
type OrganizationOmniboxAction,
} from '@/utilities/organization/organizationOmnibox'
import {
buildOrganizationListHref,
type OrganizationListState,
@ -24,16 +25,16 @@ export const OrganizationFilters = ({ state }: { state: OrganizationListState })
})
const [query, setQuery] = useState('')
const withPageReset = (next: OrganizationListState): OrganizationListState => ({
...next,
page: 1,
})
const chips = useMemo(() => buildOrganizationOmniboxChips(state), [state])
const chips = useMemo(() => buildSearchOnlyOmniboxChips(state), [state])
const suggestionSeeds = useMemo(() => buildOrganizationOmniboxSuggestionSeeds(), [])
const suggestions = useMemo(() => buildSearchOnlyOmniboxSuggestions(query), [query])
const suggestions = useMemo(
() => filterOrganizationOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: SearchOnlyOmniboxAction<OrganizationListState>) => {
const runAction = (action: OrganizationOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
@ -51,41 +52,29 @@ export const OrganizationFilters = ({ state }: { state: OrganizationListState })
>
<CampaignListOmnibox
id="organization-omnibox"
label="Buscar organização por nome"
placeholder="Digite para buscar por nome…"
label="Buscar organização por nome ou tipo"
placeholder="Digite para buscar por nome ou tipo…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId,
withPageReset,
}),
)
runAction(applyOrganizationOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(
applySearchOnlyOmniboxSuggestion({
applyOrganizationOmniboxSuggestion({
state,
suggestionId: `q:${text}`,
withPageReset,
}),
)
}}
onRemoveChip={(chipId) => {
runAction(removeSearchOnlyOmniboxChip({ state, chipId, withPageReset }))
runAction(removeOrganizationOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(
clearSearchOnlyOmnibox({
state,
cleared: withPageReset({ page: 1, ...(state.kind ? { kind: state.kind } : {}) }),
}),
)
runAction(clearOrganizationOmnibox(state))
}}
/>
</form>

View file

@ -0,0 +1,116 @@
/**
* Organization list omnibox adapter (B139). Search + exclusive kind filter.
* Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
organizationKindLabels,
organizationKinds,
type OrganizationKind,
} from '@/lib/schemas/organization'
import type { OrganizationListState } from '@/utilities/organization/organizationListUrl'
export type OrganizationOmniboxAction =
| { kind: 'url'; state: OrganizationListState }
| { kind: 'clear'; state: OrganizationListState }
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: OrganizationListState): OrganizationListState => ({
...state,
page: 1,
})
export const buildOrganizationOmniboxChips = (
state: OrganizationListState,
): CampaignListOmniboxChip[] => {
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', organizationKindLabels[state.kind]),
})
}
return chips
}
export const buildOrganizationOmniboxSuggestionSeeds = () =>
organizationKinds.map((kind) =>
createOmniboxSuggestionSeed(
{
id: `kind:${kind}`,
group: 'Tipo',
label: organizationKindLabels[kind],
keywords: ['tipo', 'organizacao'],
},
{ emptyQueryVisible: true },
),
)
export const filterOrganizationOmniboxSuggestions = (
seeds: ReturnType<typeof buildOrganizationOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyOrganizationOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: OrganizationListState
suggestionId: string
}): OrganizationOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (!suggestionId.startsWith('kind:')) return { kind: 'url', state }
const value = suggestionId.slice(5)
if (!organizationKinds.includes(value as OrganizationKind)) {
return { kind: 'url', state }
}
const kind = value as OrganizationKind
return {
kind: 'url',
state: withPageReset({
...state,
kind: state.kind === kind ? undefined : kind,
}),
}
}
export const removeOrganizationOmniboxChip = ({
state,
chipId,
}: {
state: OrganizationListState
chipId: string
}): OrganizationOmniboxAction => {
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
}
if (chipId.startsWith('kind:')) {
return { kind: 'url', state: withPageReset({ ...state, kind: undefined }) }
}
return { kind: 'url', state }
}
export const clearOrganizationOmnibox = (
_state: OrganizationListState,
): OrganizationOmniboxAction => ({
kind: 'clear',
state: withPageReset({ page: 1 }),
})

View file

@ -16,6 +16,15 @@ import {
buildDemandOmniboxChips,
clearDemandOmnibox,
} from '@/utilities/demand/demandOmnibox'
import { parseOrganizationListParams } from '@/utilities/organization/organizationListUrl'
import {
applyOrganizationOmniboxSuggestion,
buildOrganizationOmniboxChips,
buildOrganizationOmniboxSuggestionSeeds,
clearOrganizationOmnibox,
filterOrganizationOmniboxSuggestions,
removeOrganizationOmniboxChip,
} from '@/utilities/organization/organizationOmnibox'
import { parseLeadershipListParams } from '@/utilities/leadership/leadershipListUrl'
import {
applyLeadershipOmniboxSuggestion,
@ -134,6 +143,45 @@ describe('list omnibox adapters (B128)', () => {
if (cleared.kind === 'clear') expect(cleared.state.activityId).toBe(42)
})
it('organization toggles exclusive kind and coexists with search', () => {
const base = parseOrganizationListParams({})
const applied = applyOrganizationOmniboxSuggestion({
state: base,
suggestionId: 'kind:sindicato',
})
expect(applied.kind).toBe('url')
if (applied.kind === 'url') expect(applied.state.kind).toBe('sindicato')
const toggled = applyOrganizationOmniboxSuggestion({
state: parseOrganizationListParams({ kind: 'sindicato' }),
suggestionId: 'kind:sindicato',
})
if (toggled.kind === 'url') expect(toggled.state.kind).toBeUndefined()
const chips = buildOrganizationOmniboxChips(
parseOrganizationListParams({ q: 'cut', kind: 'associacao' }),
)
expect(chips.map((chip) => chip.id)).toEqual(['q', 'kind:associacao'])
const seeds = buildOrganizationOmniboxSuggestionSeeds()
const suggestions = filterOrganizationOmniboxSuggestions(seeds, 'sindicato')
expect(suggestions.some((entry) => entry.id === 'kind:sindicato')).toBe(true)
const removed = removeOrganizationOmniboxChip({
state: parseOrganizationListParams({ q: 'cut', kind: 'movimento' }),
chipId: 'kind:movimento',
})
if (removed.kind === 'url') {
expect(removed.state.kind).toBeUndefined()
expect(removed.state.q).toBe('cut')
}
const cleared = clearOrganizationOmnibox(parseOrganizationListParams({ q: 'x', kind: 'outro' }))
if (cleared.kind === 'clear') {
expect(cleared.state).toEqual({ page: 1 })
}
})
it('search-only omnibox commits q', () => {
const chips = buildSearchOnlyOmniboxChips({ q: 'sindicato' })
expect(chips[0]?.label).toBe('Busca: sindicato')