Merge branch 'cursor/f71ce1d5' (B47 home search)
This commit is contained in:
commit
a75fe2c88f
13 changed files with 408 additions and 20 deletions
|
|
@ -8,9 +8,10 @@ alwaysApply: false
|
|||
## Status
|
||||
|
||||
- **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
|
||||
- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47**. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md). Próximo: **B47** busca global.
|
||||
- **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). Próximo: **B48** resultados Municípios.
|
||||
- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47** ✓. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md).
|
||||
- **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md).
|
||||
- **B46–B55 registrados no roadmap (2026-07-29):** ~~thumb-zone do Início (**B46**)~~ entregue; busca global fatiada (**B47** input → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 ✓ → B47 → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only na busca; clique = rotas de detalhe existentes; TIs no grupo Municípios.
|
||||
- **B46–B55 registrados no roadmap (2026-07-29):** ~~thumb-zone do Início (**B46**)~~ entregue; busca global fatiada (**B47** input ✓ → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 ✓ → B47 ✓ → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only na busca; clique = rotas de detalhe existentes; TIs no grupo Municípios.
|
||||
- **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). Montado no Início por **B45** ✓. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md).
|
||||
- **B43 entregue em código (2026-07-29):** chassis UX-1 — `/campanha` fica só shell (`CampaignPageShell` vazio); dashboard staff move para `/campanha/quadro` (corpo idêntico ao antigo Início); `LeaderContactsPanel` em `/campanha/contatos`; nav staff ganha **Quadro** (sidebar; fora do bottom nav mobile); liderança **Início** + **Meus contatos**; `requireCampaignPageActor` redireciona leader bloqueado para `/campanha/contatos` (não para o Início blank). Sem migration. Plano: [`inicio-em-branco-quadro.md`](../../../docs/plans/inicio-em-branco-quadro.md). Desbloqueia **B44** / **B45**.
|
||||
- **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → ~~B45~~ ✓ → ~~B46~~ ✓ → **B47–B55** busca global (wizards depois).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Busca global do Início — input e modo resultados
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-29
|
||||
Status: entregue (2026-07-29)
|
||||
Atualizado em: 2026-07-29 — as-built: `CampaignHomeStaffChrome` (client) + `CampaignHomeSearch` + `useHomeSearchQuery` + `HomeSearchContext`; contrato `src/lib/campaignHomeSearchContract.ts` (debounce 250 ms, mín. 2 chars); staff-only em `page.tsx`; modo focado esconde strip/spacer via `CampaignHomeLayout` `focused`; região de resultados vazia (hits = B48+); DOM mantém ordem B46 (`order-*`); sem endpoint; Escape limpa query. Unit `campaignHomeSearchQuery` + `campaignHomeSearch`; e2e B47 em `campaignHomeActions`. Sem migration.
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B47 — busca global)
|
||||
Impeccable: C — superfície nova de busca no Início (expand/collapse + empty results region)
|
||||
Appetite: ~1–1,5 dia eng; ilha client + debounce + chrome de modo focado; sem provider de hits ainda
|
||||
|
|
@ -93,6 +93,13 @@ Componentes:
|
|||
|
||||
- **`?q=` na URL do Início** (shareable). Revisitar se mesa pedir mandar link da busca.
|
||||
- **Busca na liderança (apoiadores).** Revisitar se leader pedir achar contato sem rolar a lista.
|
||||
- **Contexto / boundary só com query debounced para grupos B48+** (evitar re-render por keystroke nos providers). Revisitar ao plugar o primeiro provider com trabalho não trivial em `searchResults`.
|
||||
- **`useDebouncedState` compartilhado com `AsyncSearchCombobox`.** Revisitar no 3º call site client-only com o mesmo contrato de debounce.
|
||||
- **Fundir wrapper `CampaignHomeStaffChrome` em `CampaignHomeLayout`.** Revisitar se `data-home-focused` ganhar CSS além de `focused` prop (B48 layout).
|
||||
|
||||
## Já resolvido no simplify pós-B47 (não reabrir)
|
||||
|
||||
- Estado `isDebouncing` derivado (`raw !== debounced`); `HomeSearchController` como tipo único; JSDoc de `focused`; `aria-busy` deduplicado no input.
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-29 — fill-in Cenário junto aos filtros (municípios) ✓; B46 ✓ (thumb-zone ações no Início UX-1); B58 registrado (polimento strip); B56–B57 registrados; B47–B55 busca global fatiada.
|
||||
Atualizado em: 2026-07-29 — B47 ✓ (busca global input + modo focado no Início); fill-in Cenário junto aos filtros (municípios) ✓; B46 ✓; B58 registrado; B48–B55 resultados busca.
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -83,13 +83,13 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
|
||||
### Demais itens abertos
|
||||
|
||||
- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → **B58** (polimento strip) → **B47–B55** (busca global fatiada) · **B56** (resumo total+cobertura no topo do Início); **B57** delta 7d = futuro/baixa prioridade; wizards com escrita = fatias seguintes.
|
||||
- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → **B58** (polimento strip) → **B48–B55** (resultados busca) · **B56** (resumo total+cobertura no topo do Início); **B57** delta 7d = futuro/baixa prioridade; wizards com escrita = fatias seguintes.
|
||||
- ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md)
|
||||
- ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md)
|
||||
- ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md)
|
||||
- **B58** Polimento visual da strip de ações do Início — remover heading "O que você quer fazer?"; rótulos curtos (Ajustar votos, Registrar sinal, …; atalho → **"Ver esquecidos"** _(proposto — validar com produto)_); scale discreto no círculo (hover/long-press) + opacity no active; scrollbar horizontal oculta (pan preservado) · Depende de B45 ✓ · ~0,5d · Janela 1 · Paralelizável com ~~B46~~ ✓ · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md)
|
||||
- ~~**B46** Posição dos botões de ação (thumb zone)~~ — **entregue 2026-07-29** (`CampaignHomeLayout`: coluna `min-h-full` + spacer mobile; strip em `data-slot="home-actions"` na metade inferior do viewport; `md+` no topo; slot `searchSlot` + `data-slot="home-search"` para B47; sem migration) · [plano](plans/posicao-botoes-acao-inicio-thumb-zone.md)
|
||||
- **B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto) · staff-only · Depende de B46 ✓ · ~1–1,5d · Janela 1 · Impeccable C · [plano](plans/busca-global-inicio-input.md)
|
||||
- ~~**B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto)~~ — **entregue 2026-07-29** (`CampaignHomeStaffChrome` + `CampaignHomeSearch` + `useHomeSearchQuery`; debounce 250 ms; query ativa ≥2 chars; `HomeSearchContext` para B48+; staff-only via `isStaffCampaignRole`; Escape limpa; região `aria-live` sem hits ainda; sem migration) · [plano](plans/busca-global-inicio-input.md)
|
||||
- **B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card) · Depende de B47 · ~1–1,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-municipios.md)
|
||||
- **B49** Busca — resultados Lideranças · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-liderancas.md)
|
||||
- **B50** Busca — resultados Assessores (access B19) · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-assessores.md)
|
||||
|
|
@ -205,7 +205,7 @@ flowchart TD
|
|||
B45n["B45 ✓ Catálogo ações<br/>por persona"]
|
||||
B58n["B58 Polish strip<br/>ações Início"]
|
||||
B46n["B46 ✓ Thumb zone<br/>ações Início"]
|
||||
B47n["B47 Busca global<br/>input + modo focado"]
|
||||
B47n["B47 ✓ Busca global<br/>input + modo focado"]
|
||||
B48n["B48 Busca<br/>Municípios + TIs"]
|
||||
B49n["B49 Busca Lideranças"]
|
||||
B50n["B50 Busca Assessores"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions'
|
||||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -10,20 +11,26 @@ export const dynamic = 'force-dynamic'
|
|||
export default async function CampaignHomePage() {
|
||||
const user = await requireCampaignPageActor()
|
||||
|
||||
const uncoveredMunicipalitiesHref = isStaffCampaignRole(user.role)
|
||||
const staff = isStaffCampaignRole(user.role)
|
||||
|
||||
const uncoveredMunicipalitiesHref = staff
|
||||
? buildMunicipalityListHref({ page: 1, coverage: 'sem_assessor', sort: 'votos' }, 1)
|
||||
: undefined
|
||||
|
||||
const actions = (
|
||||
<CampaignHomeActions
|
||||
role={user.role}
|
||||
uncoveredMunicipalitiesHref={uncoveredMunicipalitiesHref}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell aria-label="Início" className="min-h-full">
|
||||
<CampaignHomeLayout
|
||||
actions={
|
||||
<CampaignHomeActions
|
||||
role={user.role}
|
||||
uncoveredMunicipalitiesHref={uncoveredMunicipalitiesHref}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{staff ? (
|
||||
<CampaignHomeStaffChrome actions={actions} />
|
||||
) : (
|
||||
<CampaignHomeLayout actions={actions} />
|
||||
)}
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CampaignHomeLayout = ({
|
||||
actions,
|
||||
searchSlot,
|
||||
focused = false,
|
||||
}: {
|
||||
actions: ReactNode
|
||||
searchSlot?: ReactNode
|
||||
/** When true (trimmed debounced query length ≥ min), hide thumb-zone spacer and action strip. */
|
||||
focused?: boolean
|
||||
}) => (
|
||||
<div className="flex min-h-full w-full flex-col md:min-h-0">
|
||||
<div aria-hidden className="min-h-0 flex-1 md:hidden" />
|
||||
<div data-slot="home-actions" className="order-1 min-w-0 md:order-2">
|
||||
{focused ? null : (
|
||||
<div aria-hidden className="min-h-0 flex-1 md:hidden" data-slot="home-thumb-spacer" />
|
||||
)}
|
||||
<div data-slot="home-actions" className={cn('order-1 min-w-0 md:order-2', focused && 'hidden')}>
|
||||
{actions}
|
||||
</div>
|
||||
{searchSlot ? (
|
||||
<div data-slot="home-search" className="order-3 mt-4 min-w-0 md:order-1 md:mt-0 md:mb-6">
|
||||
<div
|
||||
data-slot="home-search"
|
||||
className={cn('order-3 min-w-0 md:order-1', focused ? 'mt-0' : 'mt-4 md:mt-0 md:mb-6')}
|
||||
>
|
||||
{searchSlot}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
45
src/components/campaign/dashboard/CampaignHomeSearch.tsx
Normal file
45
src/components/campaign/dashboard/CampaignHomeSearch.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
|
||||
|
||||
const HOME_SEARCH_INPUT_ID = 'homeSearchQuery'
|
||||
const HOME_SEARCH_LABEL = 'Buscar na campanha'
|
||||
|
||||
export const CampaignHomeSearch = ({ children }: { children?: ReactNode }) => {
|
||||
const { query, setRaw, clear, isDebouncing } = useHomeSearch()
|
||||
const ariaBusy = isDebouncing || undefined
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-4">
|
||||
<CampaignSearchInput
|
||||
id={HOME_SEARCH_INPUT_ID}
|
||||
label={HOME_SEARCH_LABEL}
|
||||
placeholder="Município, liderança, atividade…"
|
||||
value={query.raw}
|
||||
onChange={(event) => setRaw(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
clear()
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
aria-busy={ariaBusy}
|
||||
/>
|
||||
<div
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label="Resultados da busca"
|
||||
aria-busy={ariaBusy}
|
||||
className="min-w-0"
|
||||
data-slot="home-search-results"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
|
||||
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CampaignHomeStaffChrome = ({
|
||||
actions,
|
||||
searchResults,
|
||||
}: {
|
||||
actions: ReactNode
|
||||
/** Result groups plug in here from B48+. */
|
||||
searchResults?: ReactNode
|
||||
}) => {
|
||||
const searchState = useHomeSearchQuery()
|
||||
const focused = searchState.query.isActive
|
||||
|
||||
return (
|
||||
<HomeSearchProvider value={searchState}>
|
||||
<div
|
||||
className={cn('flex min-h-full w-full flex-col', focused && 'md:min-h-0')}
|
||||
data-home-focused={focused || undefined}
|
||||
>
|
||||
<CampaignHomeLayout
|
||||
actions={actions}
|
||||
focused={focused}
|
||||
searchSlot={<CampaignHomeSearch>{searchResults}</CampaignHomeSearch>}
|
||||
/>
|
||||
</div>
|
||||
</HomeSearchProvider>
|
||||
)
|
||||
}
|
||||
25
src/components/campaign/dashboard/HomeSearchContext.tsx
Normal file
25
src/components/campaign/dashboard/HomeSearchContext.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
|
||||
import type { HomeSearchController } from '@/components/campaign/dashboard/useHomeSearchQuery'
|
||||
|
||||
export type HomeSearchContextValue = HomeSearchController
|
||||
|
||||
const HomeSearchContext = createContext<HomeSearchContextValue | null>(null)
|
||||
|
||||
export const HomeSearchProvider = ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: HomeSearchContextValue
|
||||
children: ReactNode
|
||||
}) => <HomeSearchContext.Provider value={value}>{children}</HomeSearchContext.Provider>
|
||||
|
||||
export const useHomeSearch = (): HomeSearchContextValue => {
|
||||
const ctx = useContext(HomeSearchContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useHomeSearch must be used within HomeSearchProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
61
src/components/campaign/dashboard/useHomeSearchQuery.ts
Normal file
61
src/components/campaign/dashboard/useHomeSearchQuery.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
HOME_SEARCH_DEBOUNCE_MS,
|
||||
homeSearchQueryIsActive,
|
||||
normalizeHomeSearchRaw,
|
||||
type HomeSearchQuery,
|
||||
} from '@/lib/campaignHomeSearchContract'
|
||||
|
||||
export type HomeSearchController = {
|
||||
query: HomeSearchQuery
|
||||
setRaw: (next: string) => void
|
||||
clear: () => void
|
||||
isDebouncing: boolean
|
||||
}
|
||||
|
||||
export const useHomeSearchQuery = (): HomeSearchController => {
|
||||
const [raw, setRaw] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (raw === debounced) {
|
||||
return
|
||||
}
|
||||
const handle = window.setTimeout(() => {
|
||||
setDebounced(raw)
|
||||
}, HOME_SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
window.clearTimeout(handle)
|
||||
}
|
||||
}, [raw, debounced])
|
||||
|
||||
const trimmedDebounced = normalizeHomeSearchRaw(debounced)
|
||||
const isDebouncing = raw !== debounced
|
||||
|
||||
const query = useMemo(
|
||||
(): HomeSearchQuery => ({
|
||||
raw,
|
||||
debounced: trimmedDebounced,
|
||||
isActive: homeSearchQueryIsActive(trimmedDebounced),
|
||||
}),
|
||||
[raw, trimmedDebounced],
|
||||
)
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setRaw('')
|
||||
setDebounced('')
|
||||
}, [])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
query,
|
||||
setRaw,
|
||||
clear,
|
||||
isDebouncing,
|
||||
}),
|
||||
[query, clear, isDebouncing],
|
||||
)
|
||||
}
|
||||
16
src/lib/campaignHomeSearchContract.ts
Normal file
16
src/lib/campaignHomeSearchContract.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Debounce for Início omnibox — shorter than list search (1000 ms RSC round-trips). */
|
||||
export const HOME_SEARCH_DEBOUNCE_MS = 250
|
||||
|
||||
/** Minimum trimmed length before focused mode and result providers run. */
|
||||
const HOME_SEARCH_MIN_QUERY_LENGTH = 2
|
||||
|
||||
export type HomeSearchQuery = {
|
||||
raw: string
|
||||
debounced: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const homeSearchQueryIsActive = (trimmedDebounced: string): boolean =>
|
||||
trimmedDebounced.length >= HOME_SEARCH_MIN_QUERY_LENGTH
|
||||
|
||||
export const normalizeHomeSearchRaw = (raw: string): string => raw.trim()
|
||||
|
|
@ -9,6 +9,45 @@ const staffActionLabels = [
|
|||
'Ver quem ainda não está coberto',
|
||||
] as const
|
||||
|
||||
test.describe('Início — busca global (B47)', () => {
|
||||
test('staff sees campaign search on home', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await expect(page.getByLabel('Buscar na campanha')).toBeVisible()
|
||||
})
|
||||
|
||||
test('leader does not see campaign search on home', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const leader = await fixtures.createCampaignUser('leader', {
|
||||
name: fixtures.value('Liderança'),
|
||||
})
|
||||
|
||||
await campaign.login(page, leader.email!, leader.password)
|
||||
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('staff focused search hides action strip after debounce', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
const heading = page.getByRole('heading', { name: 'O que você quer fazer?', exact: true })
|
||||
await expect(heading).toBeVisible()
|
||||
|
||||
await page.getByLabel('Buscar na campanha').fill('ca')
|
||||
await expect(heading).toBeHidden({ timeout: 5000 })
|
||||
|
||||
await page.getByLabel('Buscar na campanha').fill('')
|
||||
await expect(heading).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Início — catálogo de ações (B45)', () => {
|
||||
test('staff sees six home actions and can open municipalities without coverage', async ({
|
||||
campaign,
|
||||
|
|
|
|||
40
tests/unit/campaignHomeSearch.unit.spec.tsx
Normal file
40
tests/unit/campaignHomeSearch.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
|
||||
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
|
||||
const providerValue = {
|
||||
query: { raw: 'ca', debounced: 'ca', isActive: true },
|
||||
setRaw: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
isDebouncing: false,
|
||||
}
|
||||
|
||||
describe('CampaignHomeLayout focused mode', () => {
|
||||
it('hides actions and thumb spacer when focused', () => {
|
||||
const { container } = render(
|
||||
<CampaignHomeLayout focused actions={<p>Actions block</p>} searchSlot={<p>Search</p>} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-slot="home-thumb-spacer"]')).toBeNull()
|
||||
expect(container.querySelector('[data-slot="home-actions"]')?.className).toContain('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CampaignHomeSearch', () => {
|
||||
it('renders search input and results region', () => {
|
||||
render(
|
||||
<HomeSearchProvider value={providerValue}>
|
||||
<CampaignHomeSearch>
|
||||
<p>Group stub</p>
|
||||
</CampaignHomeSearch>
|
||||
</HomeSearchProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Buscar na campanha')).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy()
|
||||
expect(screen.getByText('Group stub')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
101
tests/unit/campaignHomeSearchQuery.unit.spec.ts
Normal file
101
tests/unit/campaignHomeSearchQuery.unit.spec.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
|
||||
import { HOME_SEARCH_DEBOUNCE_MS } from '@/lib/campaignHomeSearchContract'
|
||||
|
||||
describe('useHomeSearchQuery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('debounces raw input before updating debounced and isActive', () => {
|
||||
const { result } = renderHook(() => useHomeSearchQuery())
|
||||
|
||||
act(() => {
|
||||
result.current.setRaw('ca')
|
||||
})
|
||||
|
||||
expect(result.current.query.debounced).toBe('')
|
||||
expect(result.current.query.isActive).toBe(false)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
expect(result.current.query.debounced).toBe('ca')
|
||||
expect(result.current.query.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('does not activate until trimmed debounced length meets minimum', () => {
|
||||
const { result } = renderHook(() => useHomeSearchQuery())
|
||||
|
||||
act(() => {
|
||||
result.current.setRaw('a')
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
expect(result.current.query.isActive).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.setRaw('ab')
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
expect(result.current.query.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('trims debounced value and treats whitespace-only as inactive', () => {
|
||||
const { result } = renderHook(() => useHomeSearchQuery())
|
||||
|
||||
act(() => {
|
||||
result.current.setRaw(' ca ')
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
expect(result.current.query.debounced).toBe('ca')
|
||||
expect(result.current.query.isActive).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setRaw(' ')
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
expect(result.current.query.debounced).toBe('')
|
||||
expect(result.current.query.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('clear resets raw, debounced, and debouncing state', () => {
|
||||
const { result } = renderHook(() => useHomeSearchQuery())
|
||||
|
||||
act(() => {
|
||||
result.current.setRaw('cairu')
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
expect(result.current.query.isActive).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.clear()
|
||||
})
|
||||
|
||||
expect(result.current.query.raw).toBe('')
|
||||
expect(result.current.query.debounced).toBe('')
|
||||
expect(result.current.query.isActive).toBe(false)
|
||||
expect(result.current.isDebouncing).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue