From 68ab54756e705d2cf27012ba89499a19ef202385 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:02:46 +0000 Subject: [PATCH 1/2] =?UTF-8?q?B139=20=E2=80=94=20tipo=20na=20omnibox=20de?= =?UTF-8?q?=20organiza=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter organizationOmnibox (q + kind exclusivo), OrganizationFilters migrado do searchOnly degenerado, testes unit B128. Co-authored-by: Francisco Solla --- docs/plans/organizacoes-omnibox-tipo-impl.md | 67 ++++++++++ .../organization/OrganizationFilters.tsx | 53 ++++---- .../organization/organizationOmnibox.ts | 116 ++++++++++++++++++ tests/unit/listOmniboxB128.unit.spec.ts | 48 ++++++++ 4 files changed, 252 insertions(+), 32 deletions(-) create mode 100644 docs/plans/organizacoes-omnibox-tipo-impl.md create mode 100644 src/utilities/organization/organizationOmnibox.ts diff --git a/docs/plans/organizacoes-omnibox-tipo-impl.md b/docs/plans/organizacoes-omnibox-tipo-impl.md new file mode 100644 index 00000000..6801ffbd --- /dev/null +++ b/docs/plans/organizacoes-omnibox-tipo-impl.md @@ -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`) diff --git a/src/components/campaign/organization/OrganizationFilters.tsx b/src/components/campaign/organization/OrganizationFilters.tsx index 839c12aa..3ba26ed2 100644 --- a/src/components/campaign/organization/OrganizationFilters.tsx +++ b/src/components/campaign/organization/OrganizationFilters.tsx @@ -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) => { + const runAction = (action: OrganizationOmniboxAction) => { if (action.kind === 'clear') { setQuery('') navigate(action.state) @@ -51,41 +52,29 @@ export const OrganizationFilters = ({ state }: { state: OrganizationListState }) > { - 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)) }} /> diff --git a/src/utilities/organization/organizationOmnibox.ts b/src/utilities/organization/organizationOmnibox.ts new file mode 100644 index 00000000..8a61a657 --- /dev/null +++ b/src/utilities/organization/organizationOmnibox.ts @@ -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, + 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 }), +}) diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts index 02ab4b39..07542b38 100644 --- a/tests/unit/listOmniboxB128.unit.spec.ts +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -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') From e731e407abec76275226c88c33e6aebb3b5dd2db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:04:25 +0000 Subject: [PATCH 2/2] style: prettier format B139 files --- docs/plans/organizacoes-omnibox-tipo-impl.md | 1 + .../campaign/organization/OrganizationFilters.tsx | 8 ++++---- tests/unit/listOmniboxB128.unit.spec.ts | 12 ++++++------ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/plans/organizacoes-omnibox-tipo-impl.md b/docs/plans/organizacoes-omnibox-tipo-impl.md index 6801ffbd..0625b9f0 100644 --- a/docs/plans/organizacoes-omnibox-tipo-impl.md +++ b/docs/plans/organizacoes-omnibox-tipo-impl.md @@ -23,6 +23,7 @@ flowchart LR ``` **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` diff --git a/src/components/campaign/organization/OrganizationFilters.tsx b/src/components/campaign/organization/OrganizationFilters.tsx index 3ba26ed2..91494bd9 100644 --- a/src/components/campaign/organization/OrganizationFilters.tsx +++ b/src/components/campaign/organization/OrganizationFilters.tsx @@ -4,6 +4,10 @@ import { useMemo, useState } from 'react' import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox' import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' +import { + buildOrganizationListHref, + type OrganizationListState, +} from '@/utilities/organization/organizationListUrl' import { applyOrganizationOmniboxSuggestion, buildOrganizationOmniboxChips, @@ -13,10 +17,6 @@ import { removeOrganizationOmniboxChip, type OrganizationOmniboxAction, } from '@/utilities/organization/organizationOmnibox' -import { - buildOrganizationListHref, - type OrganizationListState, -} from '@/utilities/organization/organizationListUrl' export const OrganizationFilters = ({ state }: { state: OrganizationListState }) => { const { navigate, isPending } = useCampaignListFilterNavigation({ diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts index 07542b38..7294e460 100644 --- a/tests/unit/listOmniboxB128.unit.spec.ts +++ b/tests/unit/listOmniboxB128.unit.spec.ts @@ -16,6 +16,12 @@ import { buildDemandOmniboxChips, clearDemandOmnibox, } from '@/utilities/demand/demandOmnibox' +import { parseLeadershipListParams } from '@/utilities/leadership/leadershipListUrl' +import { + applyLeadershipOmniboxSuggestion, + buildLeadershipOmniboxSuggestionSeeds, + filterLeadershipOmniboxSuggestions, +} from '@/utilities/leadership/leadershipOmnibox' import { parseOrganizationListParams } from '@/utilities/organization/organizationListUrl' import { applyOrganizationOmniboxSuggestion, @@ -25,12 +31,6 @@ import { filterOrganizationOmniboxSuggestions, removeOrganizationOmniboxChip, } from '@/utilities/organization/organizationOmnibox' -import { parseLeadershipListParams } from '@/utilities/leadership/leadershipListUrl' -import { - applyLeadershipOmniboxSuggestion, - buildLeadershipOmniboxSuggestionSeeds, - filterLeadershipOmniboxSuggestions, -} from '@/utilities/leadership/leadershipOmnibox' import { parseStateDeputyListParams } from '@/utilities/stateDeputyListUrl' import { applyStateDeputyOmniboxSuggestion,