mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
d0cd103b90
commit
68ab54756e
4 changed files with 252 additions and 32 deletions
67
docs/plans/organizacoes-omnibox-tipo-impl.md
Normal file
67
docs/plans/organizacoes-omnibox-tipo-impl.md
Normal 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`)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
116
src/utilities/organization/organizationOmnibox.ts
Normal file
116
src/utilities/organization/organizationOmnibox.ts
Normal 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 }),
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue