mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
Merge branch 'cursor/c357d7a9'
This commit is contained in:
commit
791c777820
22 changed files with 716 additions and 122 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.
|
||||
- **B79–B90 registrados (2026-07-30):** pós-**B73** (remover bottom nav), chrome mobile fora do Início = bottom drawer **não-modal** (ações contextuais + busca global) — chassis **B79** + catálogos por vertical **B80–B90** (Municípios, Territórios, Lideranças, Dobradinhas, Atividades, Demandas, Apoiadores, Assessores, Organizações, Contatos leader, Conceitos+Perfil). **Quadro fora** do inventário. Granularidade = uma vertical (lista+detalhe). Lote inteiro **não cortável**. Planos em `docs/plans/chassis-bottom-drawer-acoes-rapidas.md` e `acoes-rapidas-*.md`.
|
||||
- **B77 registrado (2026-07-30):** simplificar wizard ajuste de votos — três cenários numa tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm com highlight; substitui ritual `?cenario=` de B61/B76. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md).
|
||||
- **B71 entregue em código (2026-07-30):** polimento visual dos resultados de busca no Início (UX-1) — `src/lib/homeSearchUi.ts` (`HOME_SEARCH_HIT_ROW_WRAPPER_CLASS` = bleed + hover + `focus-within`; heading/list); `HomeSearchHitRow` faixa full-bleed `-mx-4 px-4 md:-mx-6 md:px-6` (mantém `onMouseDown` preventDefault de B66); todos os `HomeSearch*Group` com `ul list-none` e título `text-xs` sentence-case (sem `uppercase`/`tracking-wide`). Unit `homeSearchUi.unit.spec.tsx`. Sem migration/action/JSON. Plano: [`polimento-resultados-busca-inicio.md`](../../../docs/plans/polimento-resultados-busca-inicio.md).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Header mobile do wizard (chrome no Mandate Red)
|
||||
|
||||
Status: rascunho
|
||||
Status: entregue (2026-07-30)
|
||||
Atualizado em: 2026-07-30
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — **B75**; chassis UX-1 / emenda a B59)
|
||||
Impeccable: C — chrome novo no shell `(app)` durante `/campanha/acoes/*`; brief abaixo
|
||||
|
|
@ -132,4 +132,19 @@ Componentes:
|
|||
- `PRODUCT.md` / `DESIGN.md` — Mandate Red / Field Desk
|
||||
- AGENTS.md — naming; sem Consent novo
|
||||
|
||||
## As-built (2026-07-30)
|
||||
|
||||
- `CampaignWizardChromeProvider` envolve o inset em `(app)/layout.tsx`; `CampaignMobileTopBar` troca modo app (sidebar+marca) ↔ modo wizard quando o shell publica chrome.
|
||||
- `CampaignWizardShell` chama `useSetCampaignWizardChrome` (`useLayoutEffect` para publish + cleanup só no unmount; `/simplify` corrigiu flash app-mode entre passos); mobile sem `<header>` sticky branco; desktop mantém caption de município no `<main>` (`md:block`).
|
||||
- `wizardFlowTitleForSlug` deriva o título do catálogo B45 via `campaignWizardActionIdForSlug` + `wizardFlowTitleForActionId`.
|
||||
- Skip encadeado (B63/B64/B70) usa prop `skip` no shell — slot direito do header no mobile; desktop adiado (débito no plano).
|
||||
- Testes: unit `campaignMobileTopBar`, `campaignWizardShell`; e2e smoke `campaignWizardChrome.e2e.spec.ts`; prewarm `/campanha/acoes/atualizar-votos` no `setup.e2e`.
|
||||
|
||||
## Explicitamente fora (pós-/simplify, auto-confirm close-delivery)
|
||||
|
||||
- **Split read/write context do provider** — defer até medição de re-render no inset ao trocar passo (`setChrome` hoje re-renderiza filhos do provider).
|
||||
- **Mover provider só para `acoes/layout`** — defer; arquitetura maior que o appetite do B75.
|
||||
- **Mapa estático de títulos fora de `campaignHomeActions`** — defer até bundle do wizard medir custo do import Lucide na cadeia `wizardFlowTitleForSlug`.
|
||||
- **Skip no desktop** — já adiado; link textual no conteúdo quando B63+ precisar.
|
||||
|
||||
Qualidade de decisão: 4/5
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,5 +5,9 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
|||
export default async function CampaignActionsLayout({ children }: { children: ReactNode }) {
|
||||
await requireCampaignPageActor({ gate: 'staff' })
|
||||
|
||||
return <div className="mx-auto flex min-h-full w-full max-w-lg flex-col">{children}</div>
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-lg flex-col" data-wizard-route>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import {
|
|||
type BiometricEnrollmentOffer,
|
||||
} from '@/components/campaign/shell/BiometricEnrollmentToast'
|
||||
import { CampaignBottomNav } from '@/components/campaign/shell/CampaignBottomNav'
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
|
||||
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
|
||||
import {
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
|
|
@ -69,28 +71,22 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
|
||||
<CampaignSidebar user={campaignUserShellView(user)} />
|
||||
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
|
||||
<header className="flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden print:hidden">
|
||||
<SidebarTrigger className="text-primary-foreground" />
|
||||
<div className="min-w-0 leading-tight">
|
||||
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
|
||||
<span className="block truncate text-xs text-primary-foreground/80">
|
||||
Campanha · Bahia
|
||||
</span>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
</header>
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0"
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
</header>
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0"
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
|
||||
</div>
|
||||
<CampaignBottomNav role={user.role} />
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
|
||||
<CampaignBottomNav role={user.role} />
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,36 @@
|
|||
'use client'
|
||||
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react'
|
||||
import { useEffect, useId, useMemo, useRef, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
toCampaignWizardChromeState,
|
||||
useSetCampaignWizardChrome,
|
||||
type CampaignWizardChromeSkip,
|
||||
} from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CampaignWizardShellProps = {
|
||||
flowTitle: string
|
||||
stepTitle: string
|
||||
isEntryStep: boolean
|
||||
previousHref: string
|
||||
dismissHref?: string
|
||||
municipalityLabel?: string
|
||||
skip?: CampaignWizardChromeSkip
|
||||
trailingAction?: ReactNode
|
||||
contentAlign?: 'start' | 'end'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const CampaignWizardShell = ({
|
||||
flowTitle,
|
||||
stepTitle,
|
||||
isEntryStep,
|
||||
previousHref,
|
||||
dismissHref = CAMPAIGN_HOME,
|
||||
municipalityLabel,
|
||||
skip,
|
||||
trailingAction,
|
||||
contentAlign = 'start',
|
||||
children,
|
||||
|
|
@ -27,40 +38,28 @@ export const CampaignWizardShell = ({
|
|||
const titleRef = useRef<HTMLHeadingElement>(null)
|
||||
const titleId = useId()
|
||||
|
||||
const chrome = useMemo(
|
||||
() =>
|
||||
toCampaignWizardChromeState({
|
||||
flowTitle,
|
||||
isEntryStep,
|
||||
previousHref,
|
||||
dismissHref,
|
||||
municipalityLabel,
|
||||
skip,
|
||||
}),
|
||||
[dismissHref, flowTitle, isEntryStep, municipalityLabel, previousHref, skip],
|
||||
)
|
||||
|
||||
useSetCampaignWizardChrome(chrome)
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus()
|
||||
}, [stepTitle])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full flex-col">
|
||||
<header className="sticky top-0 z-10 border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="relative flex min-h-11 items-center justify-center gap-2 px-2 pb-1 pt-[max(0.25rem,env(safe-area-inset-top))]">
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 md:hidden"
|
||||
data-slot="wizard-mobile-back"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="min-h-11 gap-1 px-2" asChild>
|
||||
<Link href={previousHref}>
|
||||
<ArrowLeft className="size-4 shrink-0" aria-hidden />
|
||||
Voltar
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{municipalityLabel ? (
|
||||
<p
|
||||
className="max-w-[min(100%,14rem)] truncate px-12 text-center text-sm text-muted-foreground sm:max-w-md"
|
||||
aria-label={`Município em atualização: ${municipalityLabel}`}
|
||||
>
|
||||
{municipalityLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{trailingAction ? (
|
||||
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 items-center">
|
||||
{trailingAction}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
{trailingAction ? <div className="hidden justify-end md:flex">{trailingAction}</div> : null}
|
||||
|
||||
<main
|
||||
aria-labelledby={titleId}
|
||||
|
|
@ -69,6 +68,11 @@ export const CampaignWizardShell = ({
|
|||
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
{municipalityLabel ? (
|
||||
<p className="hidden max-w-prose truncate text-sm text-muted-foreground md:block">
|
||||
{municipalityLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<h1
|
||||
ref={titleRef}
|
||||
id={titleId}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER,
|
||||
WIZARD_VOTES_SAVE_ERROR_MESSAGE,
|
||||
WIZARD_VOTES_SAVED_MESSAGE,
|
||||
wizardFlowTitleForSlug,
|
||||
wizardVoteReturnToScenarioLabel,
|
||||
} from '@/lib/campaignWizardCopy'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
|
|
@ -181,8 +182,11 @@ export const WizardExpectedVotesStep = ({
|
|||
if (phase === 'saved') {
|
||||
return (
|
||||
<CampaignWizardShell
|
||||
flowTitle={wizardFlowTitleForSlug(actionSlug)}
|
||||
stepTitle="Votos atualizados"
|
||||
isEntryStep={false}
|
||||
previousHref={CAMPAIGN_HOME}
|
||||
dismissHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
>
|
||||
<div className="flex flex-col gap-4" role="status" aria-live="polite">
|
||||
|
|
@ -198,8 +202,11 @@ export const WizardExpectedVotesStep = ({
|
|||
|
||||
return (
|
||||
<CampaignWizardShell
|
||||
flowTitle={wizardFlowTitleForSlug(actionSlug)}
|
||||
stepTitle={wizardVoteStepTitle(currentScenario)}
|
||||
isEntryStep={false}
|
||||
previousHref={previousHref}
|
||||
dismissHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
WIZARD_MUNICIPALITY_SEARCH_LABEL,
|
||||
WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER,
|
||||
WIZARD_MUNICIPALITY_STEP_TITLE,
|
||||
wizardFlowTitleForSlug,
|
||||
} from '@/lib/campaignWizardCopy'
|
||||
import { HOME_SEARCH_QUERY_MAX_LENGTH } from '@/lib/schemas/homeSearch'
|
||||
|
||||
|
|
@ -86,7 +87,13 @@ export const WizardMunicipalitySearchStep = ({
|
|||
query.isActive && results.status === 'success' && results.municipalities.length === 0
|
||||
|
||||
return (
|
||||
<CampaignWizardShell stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE} previousHref={previousHref}>
|
||||
<CampaignWizardShell
|
||||
flowTitle={wizardFlowTitleForSlug(actionSlug)}
|
||||
stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE}
|
||||
isEntryStep
|
||||
previousHref={previousHref}
|
||||
dismissHref={previousHref}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<CampaignSearchInput
|
||||
id={WIZARD_MUNICIPALITY_SEARCH_INPUT_ID}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { wizardActionHref } from '@/lib/campaignActionRoutes'
|
||||
import { wizardNextStepPlaceholder, wizardNextStepTitle } from '@/lib/campaignWizardCopy'
|
||||
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
|
||||
import {
|
||||
wizardFlowTitleForSlug,
|
||||
wizardNextStepPlaceholder,
|
||||
wizardNextStepTitle,
|
||||
} from '@/lib/campaignWizardCopy'
|
||||
|
||||
type WizardMunicipalitySelectedStubProps = {
|
||||
actionSlug: string
|
||||
|
|
@ -12,8 +17,11 @@ export const WizardMunicipalitySelectedStub = ({
|
|||
municipalityName,
|
||||
}: WizardMunicipalitySelectedStubProps) => (
|
||||
<CampaignWizardShell
|
||||
flowTitle={wizardFlowTitleForSlug(actionSlug)}
|
||||
stepTitle={wizardNextStepTitle(actionSlug)}
|
||||
isEntryStep={false}
|
||||
previousHref={wizardActionHref(actionSlug)}
|
||||
dismissHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>
|
||||
|
|
|
|||
107
src/components/campaign/shell/CampaignMobileTopBar.tsx
Normal file
107
src/components/campaign/shell/CampaignMobileTopBar.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
'use client'
|
||||
|
||||
import { ArrowLeft, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SidebarTrigger } from '@/components/ui/Sidebar'
|
||||
import {
|
||||
WIZARD_APP_TOP_BAR_ARIA_LABEL,
|
||||
WIZARD_DISMISS_ARIA_LABEL,
|
||||
wizardFlowChromeAriaLabel,
|
||||
wizardMunicipalityChromeAriaLabel,
|
||||
} from '@/lib/campaignWizardCopy'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CampaignMobileTopBar = () => {
|
||||
const chrome = useCampaignWizardChrome()
|
||||
|
||||
if (chrome) {
|
||||
return (
|
||||
<header
|
||||
data-slot="campaign-mobile-top-bar"
|
||||
data-mode="wizard"
|
||||
aria-label={wizardFlowChromeAriaLabel(chrome.flowTitle)}
|
||||
className={cn(
|
||||
'flex min-h-14 shrink-0 items-center gap-2 bg-primary px-2 text-primary-foreground md:hidden print:hidden',
|
||||
'pt-[max(0px,env(safe-area-inset-top))]',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{chrome.stepKind === 'continue' && chrome.previousHref ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 gap-1 px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={chrome.previousHref}>
|
||||
<ArrowLeft className="size-4 shrink-0" aria-hidden />
|
||||
Voltar
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="size-11 shrink-0" aria-hidden />
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 px-1 text-center leading-tight">
|
||||
<span className="block truncate text-sm font-semibold">{chrome.flowTitle}</span>
|
||||
{chrome.municipalityLabel ? (
|
||||
<span
|
||||
className="block truncate text-xs text-primary-foreground/80"
|
||||
aria-label={wizardMunicipalityChromeAriaLabel(chrome.municipalityLabel)}
|
||||
>
|
||||
{chrome.municipalityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
{chrome.skip ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 max-w-[9rem] px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={chrome.skip.href} title={chrome.skip.label}>
|
||||
{chrome.skip.label}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={chrome.dismissHref} aria-label={WIZARD_DISMISS_ARIA_LABEL}>
|
||||
<X className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot="campaign-mobile-top-bar"
|
||||
data-mode="app"
|
||||
aria-label={WIZARD_APP_TOP_BAR_ARIA_LABEL}
|
||||
className={cn(
|
||||
'flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden print:hidden',
|
||||
'pt-[max(0px,env(safe-area-inset-top))]',
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger className="text-primary-foreground" />
|
||||
<div className="min-w-0 leading-tight">
|
||||
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
|
||||
<span className="block truncate text-xs text-primary-foreground/80">Campanha · Bahia</span>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
|
||||
export type CampaignWizardChromeSkip = {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export type CampaignWizardChromeState = {
|
||||
flowTitle: string
|
||||
municipalityLabel?: string
|
||||
stepKind: 'entry' | 'continue'
|
||||
previousHref?: string
|
||||
dismissHref: string
|
||||
skip?: CampaignWizardChromeSkip
|
||||
}
|
||||
|
||||
type CampaignWizardChromeContextValue = {
|
||||
chrome: CampaignWizardChromeState | null
|
||||
setChrome: Dispatch<SetStateAction<CampaignWizardChromeState | null>>
|
||||
}
|
||||
|
||||
const CampaignWizardChromeContext = createContext<CampaignWizardChromeContextValue | null>(null)
|
||||
|
||||
export const CampaignWizardChromeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [chrome, setChrome] = useState<CampaignWizardChromeState | null>(null)
|
||||
const value = useMemo(() => ({ chrome, setChrome }), [chrome])
|
||||
|
||||
return (
|
||||
<CampaignWizardChromeContext.Provider value={value}>
|
||||
{children}
|
||||
</CampaignWizardChromeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useCampaignWizardChromeContext = (): CampaignWizardChromeContextValue => {
|
||||
const value = useContext(CampaignWizardChromeContext)
|
||||
if (!value) {
|
||||
throw new Error('CampaignWizardChromeProvider is required')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const useCampaignWizardChrome = (): CampaignWizardChromeState | null =>
|
||||
useCampaignWizardChromeContext().chrome
|
||||
|
||||
type CampaignWizardChromeInput = {
|
||||
flowTitle: string
|
||||
isEntryStep: boolean
|
||||
previousHref: string
|
||||
dismissHref: string
|
||||
municipalityLabel?: string
|
||||
skip?: CampaignWizardChromeSkip
|
||||
}
|
||||
|
||||
export const toCampaignWizardChromeState = ({
|
||||
flowTitle,
|
||||
isEntryStep,
|
||||
previousHref,
|
||||
dismissHref,
|
||||
municipalityLabel,
|
||||
skip,
|
||||
}: CampaignWizardChromeInput): CampaignWizardChromeState => ({
|
||||
flowTitle,
|
||||
municipalityLabel,
|
||||
stepKind: isEntryStep ? 'entry' : 'continue',
|
||||
previousHref: isEntryStep ? undefined : previousHref,
|
||||
dismissHref,
|
||||
skip,
|
||||
})
|
||||
|
||||
export const useSetCampaignWizardChrome = (state: CampaignWizardChromeState | null): void => {
|
||||
const { setChrome } = useCampaignWizardChromeContext()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setChrome(state)
|
||||
}, [setChrome, state])
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
setChrome(null)
|
||||
},
|
||||
[setChrome],
|
||||
)
|
||||
}
|
||||
|
|
@ -22,8 +22,18 @@ export const CAMPAIGN_WIZARD_ACTION_SLUGS: Record<CampaignWizardActionId, string
|
|||
|
||||
const wizardActionSlugs = new Set(Object.values(CAMPAIGN_WIZARD_ACTION_SLUGS))
|
||||
|
||||
const campaignWizardSlugToId = Object.fromEntries(
|
||||
Object.entries(CAMPAIGN_WIZARD_ACTION_SLUGS).map(([id, actionSlug]) => [
|
||||
actionSlug,
|
||||
id as CampaignWizardActionId,
|
||||
]),
|
||||
) as Record<string, CampaignWizardActionId>
|
||||
|
||||
export const isCampaignWizardActionSlug = (slug: string): boolean => wizardActionSlugs.has(slug)
|
||||
|
||||
export const campaignWizardActionIdForSlug = (slug: string): CampaignWizardActionId | undefined =>
|
||||
campaignWizardSlugToId[slug]
|
||||
|
||||
export const isCampaignWizardActionId = (id: string): id is CampaignWizardActionId =>
|
||||
id in CAMPAIGN_WIZARD_ACTION_SLUGS
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
Users,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { campaignActionEntryHref, isCampaignWizardActionId } from '@/lib/campaignActionRoutes'
|
||||
import {
|
||||
campaignActionEntryHref,
|
||||
isCampaignWizardActionId,
|
||||
type CampaignWizardActionId,
|
||||
} from '@/lib/campaignActionRoutes'
|
||||
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
|
||||
import type { CampaignRole } from '@/lib/campaignRoles'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
|
|
@ -104,6 +108,11 @@ const leaderHomeActions: readonly CampaignHomeAction[] = [
|
|||
},
|
||||
]
|
||||
|
||||
export const wizardFlowTitleForActionId = (id: CampaignWizardActionId): string => {
|
||||
const action = staffHomeActionsCoordinator.find((entry) => entry.id === id)
|
||||
return action?.label ?? 'Continuar'
|
||||
}
|
||||
|
||||
export const homeActionsForRole = (role: CampaignRole): readonly CampaignHomeAction[] => {
|
||||
if (role === 'leader') return leaderHomeActions
|
||||
if (!isStaffCampaignRole(role)) return []
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
|
||||
import {
|
||||
CAMPAIGN_WIZARD_ACTION_SLUGS,
|
||||
campaignWizardActionIdForSlug,
|
||||
} from '@/lib/campaignActionRoutes'
|
||||
import { wizardFlowTitleForActionId } from '@/lib/campaignHomeActions'
|
||||
|
||||
/** Shared copy for UX-1 wizard steps (B60 municipality search, B61 vote adjustment). */
|
||||
export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const
|
||||
|
||||
export const WIZARD_DISMISS_ARIA_LABEL = 'Sair da ação' as const
|
||||
|
||||
export const WIZARD_APP_TOP_BAR_ARIA_LABEL = 'Campanha Jorge Solla' as const
|
||||
|
||||
export const wizardMunicipalityChromeAriaLabel = (municipalityLabel: string): string =>
|
||||
`Município em atualização: ${municipalityLabel}`
|
||||
|
||||
export const wizardFlowChromeAriaLabel = (flowTitle: string): string => `Ação: ${flowTitle}`
|
||||
|
||||
export const wizardFlowTitleForSlug = (actionSlug: string): string => {
|
||||
const actionId = campaignWizardActionIdForSlug(actionSlug)
|
||||
return actionId ? wizardFlowTitleForActionId(actionId) : 'Continuar'
|
||||
}
|
||||
|
||||
export const WIZARD_MUNICIPALITY_SEARCH_LABEL = 'Buscar município' as const
|
||||
|
||||
export const WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER = 'Nome do município…' as const
|
||||
|
|
|
|||
55
tests/e2e/campaignWizardChrome.e2e.spec.ts
Normal file
55
tests/e2e/campaignWizardChrome.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js'
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test.describe('Wizard — header mobile (B75)', () => {
|
||||
test('entry step shows wizard flow title and dismiss without sidebar trigger', 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 page.goto('/campanha/acoes/atualizar-votos')
|
||||
|
||||
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]')
|
||||
await expect(topBar).toBeVisible()
|
||||
await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Sair da ação' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Voltar/ })).toHaveCount(0)
|
||||
|
||||
await page.getByRole('link', { name: 'Sair da ação' }).click()
|
||||
await page.waitForURL(/\/campanha\/?$/)
|
||||
})
|
||||
|
||||
test('continue step shows back control and municipality in wizard header', 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 page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]')
|
||||
await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible()
|
||||
await expect(topBar.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Voltar/ })).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /Voltar/ }).click()
|
||||
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\/?$/)
|
||||
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
@ -15,6 +15,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
'/campanha/demandas',
|
||||
'/campanha/liderancas',
|
||||
'/campanha/conceitos',
|
||||
'/campanha/acoes/atualizar-votos',
|
||||
'/campanha/convite/e2e-prewarm',
|
||||
'/',
|
||||
]) {
|
||||
|
|
|
|||
17
tests/helpers/matchMedia.ts
Normal file
17
tests/helpers/matchMedia.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { vi } from 'vitest'
|
||||
|
||||
export const stubMatchMedia = (matches = false): void => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
CAMPAIGN_ACTIONS_HOME,
|
||||
CAMPAIGN_WIZARD_ACTION_SLUGS,
|
||||
campaignActionEntryHref,
|
||||
campaignWizardActionIdForSlug,
|
||||
isCampaignWizardActionId,
|
||||
isCampaignWizardActionSlug,
|
||||
parseWizardMunicipioParam,
|
||||
|
|
@ -55,4 +56,9 @@ describe('campaignActionRoutes', () => {
|
|||
expect(parseWizardMunicipioParam(undefined)).toBeUndefined()
|
||||
expect(parseWizardMunicipioParam(' ')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves wizard action id from slug', () => {
|
||||
expect(campaignWizardActionIdForSlug('atualizar-votos')).toBe('update-votes')
|
||||
expect(campaignWizardActionIdForSlug('inventado')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
146
tests/unit/campaignMobileTopBar.unit.spec.tsx
Normal file
146
tests/unit/campaignMobileTopBar.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import {
|
||||
CampaignWizardChromeProvider,
|
||||
useSetCampaignWizardChrome,
|
||||
type CampaignWizardChromeState,
|
||||
} from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { SidebarProvider } from '@/components/ui/Sidebar'
|
||||
import { stubMatchMedia } from '../helpers/matchMedia'
|
||||
|
||||
const ChromeProbe = ({ chrome }: { chrome: CampaignWizardChromeState | null }) => {
|
||||
useSetCampaignWizardChrome(chrome)
|
||||
return null
|
||||
}
|
||||
|
||||
const renderTopBar = (chrome: CampaignWizardChromeState | null) =>
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<ChromeProbe chrome={chrome} />
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
const renderWizardTopBar = (chrome: CampaignWizardChromeState) =>
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<ChromeProbe chrome={chrome} />
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
describe('CampaignMobileTopBar', () => {
|
||||
beforeEach(() => {
|
||||
stubMatchMedia()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders app mode with sidebar trigger when chrome is null', () => {
|
||||
const { container } = renderTopBar(null)
|
||||
|
||||
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
|
||||
expect(topBar?.getAttribute('data-mode')).toBe('app')
|
||||
expect(screen.getByText('Jorge Solla')).toBeTruthy()
|
||||
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders wizard entry mode with dismiss control and flow title', () => {
|
||||
renderWizardTopBar({
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepKind: 'entry',
|
||||
dismissHref: '/campanha',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Ajustar votos')).toBeTruthy()
|
||||
expect(screen.getByRole('link', { name: 'Sair da ação' }).getAttribute('href')).toBe(
|
||||
'/campanha',
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders wizard continue mode with back link and municipality subtitle', () => {
|
||||
renderWizardTopBar({
|
||||
flowTitle: 'Ajustar votos',
|
||||
municipalityLabel: 'Cairu',
|
||||
stepKind: 'continue',
|
||||
previousHref: '/campanha/acoes/atualizar-votos',
|
||||
dismissHref: '/campanha',
|
||||
})
|
||||
|
||||
expect(screen.getByRole('link', { name: /Voltar/ }).getAttribute('href')).toBe(
|
||||
'/campanha/acoes/atualizar-votos',
|
||||
)
|
||||
expect(screen.getByLabelText('Município em atualização: Cairu').textContent).toBe('Cairu')
|
||||
expect(screen.getByRole('link', { name: 'Sair da ação' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders skip link instead of dismiss when chained flow defines skip', () => {
|
||||
renderWizardTopBar({
|
||||
flowTitle: 'Registrar sinal',
|
||||
stepKind: 'continue',
|
||||
previousHref: '/campanha/acoes/atualizar-votos?municipio=cairu',
|
||||
dismissHref: '/campanha',
|
||||
skip: {
|
||||
label: 'Pular registro de sinal',
|
||||
href: '/campanha/acoes/mudar-tendencia?municipio=cairu',
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Pular registro de sinal' }).getAttribute('href')).toBe(
|
||||
'/campanha/acoes/mudar-tendencia?municipio=cairu',
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps wizard mode when chrome updates between steps', () => {
|
||||
const { rerender } = render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<ChromeProbe
|
||||
chrome={{
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepKind: 'entry',
|
||||
dismissHref: '/campanha',
|
||||
}}
|
||||
/>
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Jorge Solla')).toBeNull()
|
||||
|
||||
rerender(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<ChromeProbe
|
||||
chrome={{
|
||||
flowTitle: 'Ajustar votos',
|
||||
municipalityLabel: 'Cairu',
|
||||
stepKind: 'continue',
|
||||
previousHref: '/campanha/acoes/atualizar-votos',
|
||||
dismissHref: '/campanha',
|
||||
}}
|
||||
/>
|
||||
<CampaignMobileTopBar />
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
const topBar = document.querySelector('[data-slot="campaign-mobile-top-bar"]')
|
||||
expect(topBar?.getAttribute('data-mode')).toBe('wizard')
|
||||
expect(screen.queryByText('Jorge Solla')).toBeNull()
|
||||
expect(screen.getByLabelText('Município em atualização: Cairu')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
22
tests/unit/campaignWizardCopy.unit.spec.ts
Normal file
22
tests/unit/campaignWizardCopy.unit.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
WIZARD_DISMISS_ARIA_LABEL,
|
||||
wizardFlowChromeAriaLabel,
|
||||
wizardFlowTitleForSlug,
|
||||
wizardMunicipalityChromeAriaLabel,
|
||||
} from '@/lib/campaignWizardCopy'
|
||||
|
||||
describe('campaignWizardCopy', () => {
|
||||
it('maps wizard slugs to home action flow titles', () => {
|
||||
expect(wizardFlowTitleForSlug('atualizar-votos')).toBe('Ajustar votos')
|
||||
expect(wizardFlowTitleForSlug('registrar-sinal')).toBe('Registrar sinal')
|
||||
expect(wizardFlowTitleForSlug('inventado')).toBe('Continuar')
|
||||
})
|
||||
|
||||
it('builds wizard chrome aria labels from shared copy', () => {
|
||||
expect(wizardFlowChromeAriaLabel('Ajustar votos')).toBe('Ação: Ajustar votos')
|
||||
expect(wizardMunicipalityChromeAriaLabel('Cairu')).toBe('Município em atualização: Cairu')
|
||||
expect(WIZARD_DISMISS_ARIA_LABEL).toBe('Sair da ação')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,33 +1,78 @@
|
|||
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
import { SidebarProvider } from '@/components/ui/Sidebar'
|
||||
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '@/lib/campaignWizardCopy'
|
||||
import { stubMatchMedia } from '../helpers/matchMedia'
|
||||
|
||||
const renderWizardShell = (props: React.ComponentProps<typeof CampaignWizardShell>) =>
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell {...props} />
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
describe('CampaignWizardShell', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
beforeEach(() => {
|
||||
stubMatchMedia()
|
||||
})
|
||||
|
||||
it('renders mobile back link with visible Voltar label', () => {
|
||||
const { container } = render(
|
||||
<CampaignWizardShell stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE} previousHref="/campanha">
|
||||
Corpo
|
||||
</CampaignWizardShell>,
|
||||
)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const back = screen.getByRole('link', { name: /Voltar/ })
|
||||
expect(back.getAttribute('href')).toBe('/campanha')
|
||||
const mobileBackSlot = container.querySelector('[data-slot="wizard-mobile-back"]')
|
||||
expect(mobileBackSlot?.contains(back)).toBe(true)
|
||||
it('registers wizard chrome on the mobile top bar for continue steps', () => {
|
||||
renderWizardShell({
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepTitle: 'Quantos votos?',
|
||||
isEntryStep: false,
|
||||
previousHref: '/campanha/acoes/atualizar-votos',
|
||||
dismissHref: '/campanha',
|
||||
municipalityLabel: 'Cairu',
|
||||
children: 'Corpo',
|
||||
})
|
||||
|
||||
expect(screen.getByRole('link', { name: /Voltar/ }).getAttribute('href')).toBe(
|
||||
'/campanha/acoes/atualizar-votos',
|
||||
)
|
||||
const topBar = document.querySelector(
|
||||
'[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]',
|
||||
)
|
||||
expect(topBar).toBeTruthy()
|
||||
expect(
|
||||
within(topBar as HTMLElement).getByLabelText('Município em atualização: Cairu'),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('registers entry chrome without back control', () => {
|
||||
renderWizardShell({
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepTitle: WIZARD_MUNICIPALITY_STEP_TITLE,
|
||||
isEntryStep: true,
|
||||
previousHref: '/campanha',
|
||||
dismissHref: '/campanha',
|
||||
children: 'Corpo',
|
||||
})
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Sair da ação' })).toBeTruthy()
|
||||
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('labels main landmark with the step title', () => {
|
||||
const { container } = render(
|
||||
<CampaignWizardShell stepTitle="Pergunta" previousHref="/campanha">
|
||||
Corpo
|
||||
</CampaignWizardShell>,
|
||||
)
|
||||
const { container } = renderWizardShell({
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepTitle: 'Pergunta',
|
||||
isEntryStep: true,
|
||||
previousHref: '/campanha',
|
||||
children: 'Corpo',
|
||||
})
|
||||
|
||||
const main = within(container).getByRole('main')
|
||||
const heading = within(container).getByRole('heading', { level: 1, name: 'Pergunta' })
|
||||
|
|
@ -36,42 +81,52 @@ describe('CampaignWizardShell', () => {
|
|||
|
||||
it('moves focus to the step title when stepTitle changes', () => {
|
||||
const { rerender } = render(
|
||||
<CampaignWizardShell stepTitle="Passo 1" previousHref="/campanha">
|
||||
Corpo
|
||||
</CampaignWizardShell>,
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 1"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
>
|
||||
Corpo
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<CampaignWizardShell stepTitle="Passo 2" previousHref="/campanha">
|
||||
Corpo
|
||||
</CampaignWizardShell>,
|
||||
<SidebarProvider>
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignMobileTopBar />
|
||||
<CampaignWizardShell
|
||||
flowTitle="Ajustar votos"
|
||||
stepTitle="Passo 2"
|
||||
isEntryStep
|
||||
previousHref="/campanha"
|
||||
>
|
||||
Corpo
|
||||
</CampaignWizardShell>
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarProvider>,
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1, name: 'Passo 2' }))
|
||||
})
|
||||
|
||||
it('omits municipality caption when label is absent', () => {
|
||||
render(
|
||||
<CampaignWizardShell stepTitle="Pergunta" previousHref="/campanha">
|
||||
{null}
|
||||
</CampaignWizardShell>,
|
||||
)
|
||||
it('shows municipality caption in main on desktop breakpoint classes', () => {
|
||||
const { container } = renderWizardShell({
|
||||
flowTitle: 'Ajustar votos',
|
||||
stepTitle: 'Pergunta',
|
||||
isEntryStep: false,
|
||||
previousHref: '/campanha/acoes/atualizar-votos',
|
||||
municipalityLabel: 'Cairu',
|
||||
children: null,
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText(/Município em atualização/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows municipality caption with accessible name', () => {
|
||||
render(
|
||||
<CampaignWizardShell
|
||||
stepTitle="Pergunta"
|
||||
previousHref="/campanha/acoes/atualizar-votos"
|
||||
municipalityLabel="Cairu"
|
||||
>
|
||||
{null}
|
||||
</CampaignWizardShell>,
|
||||
)
|
||||
|
||||
const caption = screen.getByLabelText('Município em atualização: Cairu')
|
||||
expect(caption.textContent).toBe('Cairu')
|
||||
const caption = container.querySelector('main p')
|
||||
expect(caption?.textContent).toBe('Cairu')
|
||||
expect(caption?.className).toMatch(/md:block/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
|
||||
const replace = vi.fn()
|
||||
|
||||
|
|
@ -29,6 +30,13 @@ const defaultProps = {
|
|||
initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 },
|
||||
}
|
||||
|
||||
const renderVotesStep = (props: React.ComponentProps<typeof WizardExpectedVotesStep>) =>
|
||||
render(
|
||||
<CampaignWizardChromeProvider>
|
||||
<WizardExpectedVotesStep {...props} />
|
||||
</CampaignWizardChromeProvider>,
|
||||
)
|
||||
|
||||
describe('WizardExpectedVotesStep', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
|
@ -36,16 +44,18 @@ describe('WizardExpectedVotesStep', () => {
|
|||
})
|
||||
|
||||
it('shows violation banner on média immediately after pessimista breaks order', () => {
|
||||
const { rerender } = render(
|
||||
<WizardExpectedVotesStep {...defaultProps} currentScenario="pessimistic" />,
|
||||
)
|
||||
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
|
||||
|
||||
expect(replace).toHaveBeenCalledWith(expect.stringContaining('cenario=central'))
|
||||
|
||||
rerender(<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />)
|
||||
rerender(
|
||||
<CampaignWizardChromeProvider>
|
||||
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
|
||||
</CampaignWizardChromeProvider>,
|
||||
)
|
||||
|
||||
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
|
||||
expect(within(step).getByRole('alert').textContent).toMatch(/Pessimista/i)
|
||||
|
|
@ -53,13 +63,15 @@ describe('WizardExpectedVotesStep', () => {
|
|||
})
|
||||
|
||||
it('clears violation banner when média draft is edited', () => {
|
||||
const { rerender } = render(
|
||||
<WizardExpectedVotesStep {...defaultProps} currentScenario="pessimistic" />,
|
||||
)
|
||||
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
|
||||
rerender(<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />)
|
||||
rerender(
|
||||
<CampaignWizardChromeProvider>
|
||||
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
|
||||
</CampaignWizardChromeProvider>,
|
||||
)
|
||||
|
||||
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
|
||||
expect(within(step).getByRole('alert')).toBeTruthy()
|
||||
|
|
|
|||
Loading…
Reference in a new issue