Merge pull request #321 from fsolla/cursor/pool-i289-de3861a0-5cd2

B135 — Wizard Voltar = passo imediatamente anterior na cadeia
This commit is contained in:
Francisco Solla 2026-08-02 20:19:52 -03:00 committed by GitHub
commit 66c84a9f6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 339 additions and 42 deletions

View file

@ -0,0 +1,61 @@
# Impl: Wizard — Voltar = passo imediatamente anterior (também na cadeia)
Status: aprovado
Atualizado em: 2026-08-02
Issue: #289
Intenção: docs/plans/wizard-voltar-passo-anterior-cadeia.md
Appetite restante: herdado (~0,5–1d)
## Leitura da intenção
- **Outcome:** Voltar (header + back do SO) no wizard `/campanha/acoes/*` navega ao passo lógico imediatamente anterior — na cadeia B98, do 1º passo de um elo encadeado volta ao passo principal do elo anterior no mesmo município, nunca à busca de município.
- **O que NÃO negociar:** staff only; sem migration; leader lockdown; `returnPath` (B110) intacto; Registrar pedido (A5) fora — só contrato plugável.
- **O que reavaliar:** hipótese de “history replace no avanço” — confirmado como causa do back cego; corrigir com `push` no avanço + `replace` no Voltar.
## Abordagem recomendada
```mermaid
flowchart LR
Steps["Wizard steps"] --> Prev["wizardPreviousHref"]
Prev --> Chain["wizardActionChain"]
Forward["Salvar / encadear"] --> Push["router.push"]
Voltar["Header Voltar"] --> Replace["router.replace(previousHref)"]
SOBack["SO back"] --> History["history stack coerente"]
```
**Opções consideradas:** A) só corrigir `previousHref` | B) `previousHref` + push/replace | C) popstate interceptor global
**Recomendação:** **B** — dono único de passo anterior em `wizardActionChain`; avanço empilha histórico; Voltar substitui entrada atual para não duplicar.
**Rejeitadas:** C (explode appetite); A sem push (back do SO continua mentindo).
### Componentes / mudanças
- **`wizardPreviousHref`** (`src/lib/wizardActionChain.ts`): `WizardStepKind` + href do passo anterior (interno, principal, elo encadeado).
- **Steps** (`Wizard*Step.tsx`): trocar `previousHref` ad hoc pelo helper.
- **`CampaignWizardNavLink`**: prop `replace` para Voltar.
- **`CampaignMobileTopBar`**: Voltar com `replace={true}`.
- **Chain continues** (`WizardExpectedVotesStep`, `WizardTrendNoteStep`, `WizardSignalBodyStep`, `WizardLeadershipStep`): `router.push` em vez de `replace`.
- **Migration:** sem migration.
- **Testes:** unit em `wizardActionChain.unit.spec.ts`; ajuste em `wizardExpectedVotesStep`, `wizardNavigationPending`.
## Fases verificáveis
1. **Tracer** — `wizardPreviousHref` + unit tests.
2. **UI** — steps + nav semantics (push/replace).
3. **Gates** — `pnpm gate:fast`; entrega `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Implementar A5 / registrar-pedido.
- Popstate handler global fora do wizard.
- Desfazer saves ao voltar.
## Riscos e mitigação
- **Histórico duplicado se Voltar usa push** — mitigado com `replace` no chrome Voltar.
- **Leadership form vs grid** — `previousHref` dinâmico por `mode`.
## Aceite de engenharia
- [x] Aceite de produto da intenção coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes unit previstos

View file

@ -20,7 +20,6 @@ import {
} from '@/components/ui/Drawer'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import {
WIZARD_LEADERSHIP_ADD_TILE_LABEL,
@ -39,6 +38,7 @@ import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardLeadershipSkip,
@ -120,7 +120,7 @@ export const WizardLeadershipStep = ({
const handleContinue = () => {
startContinueTransition(() => {
router.replace(chainContinueHref)
router.push(chainContinueHref)
})
}
@ -129,7 +129,13 @@ export const WizardLeadershipStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={stepTitle}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: mode.kind === 'form' ? 'leadership-form' : 'leadership-grid',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skipConfig}

View file

@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardSignalHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
@ -21,6 +20,7 @@ import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardSignalSkip,
@ -58,7 +58,7 @@ export const WizardSignalBodyStep = ({
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'register-signal')
router.replace(
router.push(
wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug, returnPath),
)
})
@ -68,13 +68,13 @@ export const WizardSignalBodyStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardSignalHref(
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'signal-body',
municipalitySlug,
undefined,
entryAction,
returnPath,
)}
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}

View file

@ -17,7 +17,7 @@ import {
DrawerTitle,
} from '@/components/ui/Drawer'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref, wizardSignalHref } from '@/lib/campaignActionRoutes'
import { wizardSignalHref } from '@/lib/campaignActionRoutes'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
municipalitySignalTypeMeta,
@ -25,7 +25,7 @@ import {
} from '@/lib/municipalitySignalTypeMeta'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
import { wizardChainEndHref, wizardPreviousHref } from '@/lib/wizardActionChain'
import { resolveWizardSignalSkip, WIZARD_SIGNAL_TYPE_STEP_TITLE } from '@/lib/wizardSignalUi'
type WizardSignalTypeStepProps = {
@ -53,7 +53,13 @@ export const WizardSignalTypeStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={WIZARD_SIGNAL_TYPE_STEP_TITLE}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'signal-type',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
contentAlign="end"

View file

@ -17,7 +17,7 @@ import {
DrawerTitle,
} from '@/components/ui/Drawer'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref, wizardTrendHref } from '@/lib/campaignActionRoutes'
import { wizardTrendHref } from '@/lib/campaignActionRoutes'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
politicalTrendWizardMetaByStatus,
@ -30,7 +30,7 @@ import {
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { cn } from '@/lib/utils'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
import { wizardChainEndHref, wizardPreviousHref } from '@/lib/wizardActionChain'
type WizardTrendChoiceStepProps = {
actionSlug: string
@ -61,7 +61,13 @@ export const WizardTrendChoiceStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={wizardTrendChoiceStepTitle(currentStatus)}
previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'trend-choice',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}

View file

@ -12,7 +12,6 @@ import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardTrendHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
@ -25,6 +24,7 @@ import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
@ -61,9 +61,7 @@ export const WizardTrendNoteStep = ({
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'change-trend')
router.replace(
wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath),
)
router.push(wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath))
})
return (
@ -71,14 +69,13 @@ export const WizardTrendNoteStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
isEntryStep={false}
stepTitle={stepTitle}
previousHref={wizardTrendHref(
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'trend-note',
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)}
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}

View file

@ -14,15 +14,17 @@ import { cn } from '@/lib/utils'
*/
export const CampaignWizardNavLink = ({
href,
replace = false,
className,
children,
...anchorProps
}: Omit<ComponentPropsWithoutRef<'a'>, 'href'> & { href: string }) => {
}: Omit<ComponentPropsWithoutRef<'a'>, 'href'> & { href: string; replace?: boolean }) => {
const { isPending } = useCampaignListTransition()
return (
<CampaignTransitionAnchor
href={href}
replace={replace}
className={cn(isPending && 'pointer-events-none', className)}
aria-busy={isPending || undefined}
{...anchorProps}

View file

@ -16,7 +16,6 @@ import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import {
@ -31,6 +30,7 @@ import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
applyVoteShortcut,
@ -85,9 +85,7 @@ export const WizardExpectedVotesStep = ({
const continueAfterVotes = () => {
const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes')
router.replace(
wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath),
)
router.push(wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath))
}
const handleConfirm = () => {
@ -138,7 +136,13 @@ export const WizardExpectedVotesStep = ({
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
previousHref={wizardPreviousHref({
actionSlug,
stepKind: 'votes',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}

View file

@ -56,7 +56,7 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
asChild
disabled={isPending}
>
<CampaignWizardNavLink href={chrome.previousHref}>
<CampaignWizardNavLink href={chrome.previousHref} replace>
<ArrowLeft className="size-4 shrink-0" aria-hidden />
Voltar
</CampaignWizardNavLink>

View file

@ -5,11 +5,12 @@
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
type CampaignWizardActionId,
campaignWizardActionIdForSlug,
isWizardReturnPath,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
type CampaignWizardActionId,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
@ -109,3 +110,137 @@ export const resolveWizardChainEntry = (
entryAction: CampaignWizardActionId | undefined,
currentAction: WizardChainActionId,
): WizardChainActionId => (isWizardChainActionId(entryAction) ? entryAction : currentAction)
/** Discriminates wizard chrome steps for `wizardPreviousHref` (B135). */
type WizardStepKind =
| 'municipality-search'
| 'votes'
| 'trend-choice'
| 'trend-note'
| 'signal-type'
| 'signal-body'
| 'leadership-grid'
| 'leadership-form'
const wizardPrincipalStepHref = (
action: WizardChainActionId,
municipalitySlug: string,
sessionEntry: WizardChainActionId,
returnPath?: string,
): string => {
const actionSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action]
switch (action) {
case 'update-votes':
case 'update-leadership':
return wizardActionHref(actionSlug, municipalitySlug, {
entryAction: sessionEntry,
returnPath,
})
case 'register-signal':
return wizardSignalHref(actionSlug, municipalitySlug, undefined, sessionEntry, returnPath)
case 'change-trend':
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
sessionEntry,
undefined,
returnPath,
)
default: {
const exhaustive: never = action
return exhaustive
}
}
}
/**
* Principal step of the chain link immediately before `current` in a session owned by
* `sessionEntry`. Undefined when `current` is the session principal (standalone entry).
*/
const wizardChainPreviousPrincipalHref = (
current: WizardChainActionId,
sessionEntry: WizardChainActionId,
municipalitySlug: string,
returnPath?: string,
): string | undefined => {
if (current === sessionEntry) {
return undefined
}
const queue = wizardChainAfter(sessionEntry)
const index = queue.indexOf(current)
if (index === -1) {
return undefined
}
if (index === 0) {
return wizardPrincipalStepHref(sessionEntry, municipalitySlug, sessionEntry, returnPath)
}
return wizardPrincipalStepHref(queue[index - 1], municipalitySlug, sessionEntry, returnPath)
}
type WizardPreviousHrefInput = {
actionSlug: string
stepKind: WizardStepKind
municipalitySlug?: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
/**
* Href for the logically previous wizard step — internal sub-steps, principal search,
* or the preceding B98 chain link's principal step (same município).
*/
export const wizardPreviousHref = (input: WizardPreviousHrefInput): string => {
const { actionSlug, stepKind, municipalitySlug, entryAction, returnPath } = input
if (stepKind === 'municipality-search') {
return wizardChainEndHref(returnPath)
}
if (stepKind === 'trend-note') {
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)
}
if (stepKind === 'signal-body') {
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction, returnPath)
}
if (stepKind === 'leadership-form') {
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
}
const currentActionId = campaignWizardActionIdForSlug(actionSlug)
const searchHref = wizardActionHref(actionSlug, undefined, { returnPath })
if (!municipalitySlug || !currentActionId || !isWizardChainActionId(currentActionId)) {
return searchHref
}
const sessionEntry = resolveWizardChainEntry(entryAction, currentActionId)
if (
(stepKind === 'votes' || stepKind === 'leadership-grid') &&
currentActionId === sessionEntry
) {
return searchHref
}
const chainPrev = wizardChainPreviousPrincipalHref(
currentActionId,
sessionEntry,
municipalitySlug,
returnPath,
)
if (chainPrev) {
return chainPrev
}
return searchHref
}

View file

@ -8,6 +8,7 @@ import {
resolveWizardChainEntry,
wizardChainAfter,
wizardChainContinueHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
describe('wizardActionChain', () => {
@ -87,4 +88,81 @@ describe('wizardActionChain', () => {
expect(resolveWizardChainEntry('register-signal', 'update-votes')).toBe('register-signal')
expect(resolveWizardChainEntry('register-demand', 'change-trend')).toBe('change-trend')
})
describe('wizardPreviousHref', () => {
it('returns chain-end for municipality search', () => {
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'municipality-search',
}),
).toBe(CAMPAIGN_HOME)
})
it('returns municipality search for principal post-municipio steps', () => {
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'votes',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`)
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-choice',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia`)
})
it('returns previous chain principal when chained from another entry', () => {
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-choice',
municipalitySlug: 'cairu',
entryAction: 'update-votes',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=update-votes`)
expect(
wizardPreviousHref({
actionSlug: 'registrar-sinal',
stepKind: 'signal-type',
municipalitySlug: 'cairu',
entryAction: 'update-votes',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`)
expect(
wizardPreviousHref({
actionSlug: 'atualizar-lideranca',
stepKind: 'leadership-grid',
municipalitySlug: 'cairu',
entryAction: 'register-signal',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-signal`)
})
it('returns internal previous step within a subflow', () => {
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-note',
municipalitySlug: 'cairu',
entryAction: 'update-votes',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`)
})
it('honors return path on municipality search back target', () => {
const origin = '/campanha/municipios/cairu'
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'municipality-search',
returnPath: origin,
}),
).toBe(origin)
})
})
})

View file

@ -6,12 +6,12 @@ import { CampaignWizardChromeProvider } from '@/components/campaign/shell/Campai
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
const replaceMock = vi.fn()
const pushMock = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: replaceMock,
push: pushMock,
replace: vi.fn(),
}),
}))
@ -42,7 +42,7 @@ describe('WizardExpectedVotesStep', () => {
afterEach(() => {
cleanup()
vi.mocked(postCampaignJson).mockReset()
replaceMock.mockReset()
pushMock.mockReset()
})
it('applies shortcuts to the focused scenario', () => {
@ -120,7 +120,7 @@ describe('WizardExpectedVotesStep', () => {
})
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith(
expect(pushMock).toHaveBeenCalledWith(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`,
)
})

View file

@ -4,7 +4,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
const routerState = vi.hoisted(() => ({
push: vi.fn(),
resolvePush: null as (() => void) | null,
replace: vi.fn(),
resolveNav: null as (() => void) | null,
}))
vi.mock('next/navigation', async (importActual) => ({
@ -12,7 +13,7 @@ vi.mock('next/navigation', async (importActual) => ({
usePathname: () => '/campanha/acoes/atualizar-votos',
useRouter: () => ({
push: (...args: unknown[]) => routerState.push(...args),
replace: vi.fn(),
replace: (...args: unknown[]) => routerState.replace(...args),
}),
}))
@ -65,15 +66,16 @@ describe('wizard navigation pending', () => {
afterEach(() => {
cleanup()
routerState.push.mockReset()
routerState.resolvePush = null
routerState.replace.mockReset()
routerState.resolveNav = null
vi.unstubAllGlobals()
})
it('dims step content and locks chrome while Voltar navigation is in flight', async () => {
routerState.push.mockImplementation(
routerState.replace.mockImplementation(
() =>
new Promise<void>((resolve) => {
routerState.resolvePush = resolve
routerState.resolveNav = resolve
}),
)
@ -90,7 +92,7 @@ describe('wizard navigation pending', () => {
fireEvent.click(backLink)
})
expect(routerState.push).toHaveBeenCalledWith('/campanha/acoes/atualizar-votos', {
expect(routerState.replace).toHaveBeenCalledWith('/campanha/acoes/atualizar-votos', {
scroll: true,
})
@ -105,7 +107,7 @@ describe('wizard navigation pending', () => {
})
await act(async () => {
routerState.resolvePush?.()
routerState.resolveNav?.()
})
expect(stepBody!.getAttribute('aria-busy')).not.toBe('true')