From 28525aab4b9476bbe274486b767d112512ada45a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:04:26 +0000 Subject: [PATCH 1/3] =?UTF-8?q?B135=20=E2=80=94=20Voltar=20no=20wizard=20u?= =?UTF-8?q?sa=20passo=20l=C3=B3gico=20anterior=20na=20cadeia=20(#289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wizardPreviousHref em wizardActionChain para todos os elos B98 - Avanço encadeado com router.push; Voltar no chrome com replace - Testes unitários para href anterior e navegação pending Co-authored-by: Francisco Solla --- ...izard-voltar-passo-anterior-cadeia-impl.md | 61 +++++++++ .../leadership/WizardLeadershipStep.tsx | 12 +- .../municipality/WizardSignalBodyStep.tsx | 10 +- .../municipality/WizardSignalTypeStep.tsx | 12 +- .../municipality/WizardTrendChoiceStep.tsx | 12 +- .../municipality/WizardTrendNoteStep.tsx | 11 +- .../campaign/shared/CampaignWizardNavLink.tsx | 4 +- .../shared/WizardExpectedVotesStep.tsx | 12 +- .../campaign/shell/CampaignMobileTopBar.tsx | 2 +- src/lib/wizardActionChain.ts | 128 ++++++++++++++++++ tests/unit/wizardActionChain.unit.spec.ts | 80 +++++++++++ .../wizardExpectedVotesStep.unit.spec.tsx | 10 +- .../wizardNavigationPending.unit.spec.tsx | 16 ++- 13 files changed, 333 insertions(+), 37 deletions(-) create mode 100644 docs/plans/wizard-voltar-passo-anterior-cadeia-impl.md diff --git a/docs/plans/wizard-voltar-passo-anterior-cadeia-impl.md b/docs/plans/wizard-voltar-passo-anterior-cadeia-impl.md new file mode 100644 index 00000000..f1df169d --- /dev/null +++ b/docs/plans/wizard-voltar-passo-anterior-cadeia-impl.md @@ -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 diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index b6255cb4..9fa5d61d 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -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} diff --git a/src/components/campaign/municipality/WizardSignalBodyStep.tsx b/src/components/campaign/municipality/WizardSignalBodyStep.tsx index 37a042b4..4a1aed8b 100644 --- a/src/components/campaign/municipality/WizardSignalBodyStep.tsx +++ b/src/components/campaign/municipality/WizardSignalBodyStep.tsx @@ -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} diff --git a/src/components/campaign/municipality/WizardSignalTypeStep.tsx b/src/components/campaign/municipality/WizardSignalTypeStep.tsx index 8daeb62c..334bd46d 100644 --- a/src/components/campaign/municipality/WizardSignalTypeStep.tsx +++ b/src/components/campaign/municipality/WizardSignalTypeStep.tsx @@ -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" diff --git a/src/components/campaign/municipality/WizardTrendChoiceStep.tsx b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx index 08665fe9..6f4132f5 100644 --- a/src/components/campaign/municipality/WizardTrendChoiceStep.tsx +++ b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx @@ -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} diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx index 7d69edf9..3acbc294 100644 --- a/src/components/campaign/municipality/WizardTrendNoteStep.tsx +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -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,7 +61,7 @@ export const WizardTrendNoteStep = ({ useCampaignFormSuccessToast(state, () => { recordLastActedMunicipality(municipalitySlug) const sessionEntry = resolveWizardChainEntry(entryAction, 'change-trend') - router.replace( + router.push( wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath), ) }) @@ -71,14 +71,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} diff --git a/src/components/campaign/shared/CampaignWizardNavLink.tsx b/src/components/campaign/shared/CampaignWizardNavLink.tsx index 10443ab9..8ae887f7 100644 --- a/src/components/campaign/shared/CampaignWizardNavLink.tsx +++ b/src/components/campaign/shared/CampaignWizardNavLink.tsx @@ -14,15 +14,17 @@ import { cn } from '@/lib/utils' */ export const CampaignWizardNavLink = ({ href, + replace = false, className, children, ...anchorProps -}: Omit, 'href'> & { href: string }) => { +}: Omit, 'href'> & { href: string; replace?: boolean }) => { const { isPending } = useCampaignListTransition() return ( { const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes') - router.replace( + router.push( wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath), ) } @@ -138,7 +138,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} diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx index daa02b89..2386f469 100644 --- a/src/components/campaign/shell/CampaignMobileTopBar.tsx +++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx @@ -56,7 +56,7 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: asChild disabled={isPending} > - + Voltar diff --git a/src/lib/wizardActionChain.ts b/src/lib/wizardActionChain.ts index 3327c2d6..2a382d76 100644 --- a/src/lib/wizardActionChain.ts +++ b/src/lib/wizardActionChain.ts @@ -5,6 +5,7 @@ import { CAMPAIGN_WIZARD_ACTION_SLUGS, + campaignWizardActionIdForSlug, type CampaignWizardActionId, isWizardReturnPath, wizardActionHref, @@ -109,3 +110,130 @@ export const resolveWizardChainEntry = ( entryAction: CampaignWizardActionId | undefined, currentAction: WizardChainActionId, ): WizardChainActionId => (isWizardChainActionId(entryAction) ? entryAction : currentAction) + +/** Discriminates wizard chrome steps for `wizardPreviousHref` (B135). */ +export 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 +} diff --git a/tests/unit/wizardActionChain.unit.spec.ts b/tests/unit/wizardActionChain.unit.spec.ts index 226fb067..5f3d6f74 100644 --- a/tests/unit/wizardActionChain.unit.spec.ts +++ b/tests/unit/wizardActionChain.unit.spec.ts @@ -8,6 +8,7 @@ import { resolveWizardChainEntry, wizardChainAfter, wizardChainContinueHref, + wizardPreviousHref, } from '@/lib/wizardActionChain' describe('wizardActionChain', () => { @@ -87,4 +88,83 @@ 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) + }) + }) }) diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx index 46cae242..f5f7595e 100644 --- a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx +++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx @@ -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`, ) }) diff --git a/tests/unit/wizardNavigationPending.unit.spec.tsx b/tests/unit/wizardNavigationPending.unit.spec.tsx index 40d44027..3173fee8 100644 --- a/tests/unit/wizardNavigationPending.unit.spec.tsx +++ b/tests/unit/wizardNavigationPending.unit.spec.tsx @@ -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((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') From 989e3abab89ff5ab130f21660ef60a137b0fdbaf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:05:09 +0000 Subject: [PATCH 2/3] Format B135 wizard back navigation files --- .../campaign/municipality/WizardTrendNoteStep.tsx | 4 +--- .../campaign/shared/WizardExpectedVotesStep.tsx | 4 +--- src/lib/wizardActionChain.ts | 11 +++++++++-- tests/unit/wizardActionChain.unit.spec.ts | 4 +--- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx index 3acbc294..f3d0e969 100644 --- a/src/components/campaign/municipality/WizardTrendNoteStep.tsx +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -61,9 +61,7 @@ export const WizardTrendNoteStep = ({ useCampaignFormSuccessToast(state, () => { recordLastActedMunicipality(municipalitySlug) const sessionEntry = resolveWizardChainEntry(entryAction, 'change-trend') - router.push( - wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath), - ) + router.push(wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath)) }) return ( diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index 933f07e8..a20b725b 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -85,9 +85,7 @@ export const WizardExpectedVotesStep = ({ const continueAfterVotes = () => { const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes') - router.push( - wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath), - ) + router.push(wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath)) } const handleConfirm = () => { diff --git a/src/lib/wizardActionChain.ts b/src/lib/wizardActionChain.ts index 2a382d76..7b5a2090 100644 --- a/src/lib/wizardActionChain.ts +++ b/src/lib/wizardActionChain.ts @@ -6,11 +6,11 @@ import { CAMPAIGN_WIZARD_ACTION_SLUGS, campaignWizardActionIdForSlug, - type CampaignWizardActionId, isWizardReturnPath, wizardActionHref, wizardSignalHref, wizardTrendHref, + type CampaignWizardActionId, } from '@/lib/campaignActionRoutes' import { CAMPAIGN_HOME } from '@/lib/campaignPaths' @@ -198,7 +198,14 @@ export const wizardPreviousHref = (input: WizardPreviousHrefInput): string => { } if (stepKind === 'trend-note') { - return wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction, undefined, returnPath) + return wizardTrendHref( + actionSlug, + municipalitySlug, + undefined, + entryAction, + undefined, + returnPath, + ) } if (stepKind === 'signal-body') { diff --git a/tests/unit/wizardActionChain.unit.spec.ts b/tests/unit/wizardActionChain.unit.spec.ts index 5f3d6f74..c821f1a1 100644 --- a/tests/unit/wizardActionChain.unit.spec.ts +++ b/tests/unit/wizardActionChain.unit.spec.ts @@ -132,9 +132,7 @@ describe('wizardActionChain', () => { municipalitySlug: 'cairu', entryAction: 'update-votes', }), - ).toBe( - `${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`, - ) + ).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`) expect( wizardPreviousHref({ actionSlug: 'atualizar-lideranca', From fa53f5d66e76fe5e33cacdc4896ed8f04eea9a71 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:05:45 +0000 Subject: [PATCH 3/3] Keep WizardStepKind internal (knip) --- src/lib/wizardActionChain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/wizardActionChain.ts b/src/lib/wizardActionChain.ts index 7b5a2090..e2dd7b65 100644 --- a/src/lib/wizardActionChain.ts +++ b/src/lib/wizardActionChain.ts @@ -112,7 +112,7 @@ export const resolveWizardChainEntry = ( ): WizardChainActionId => (isWizardChainActionId(entryAction) ? entryAction : currentAction) /** Discriminates wizard chrome steps for `wizardPreviousHref` (B135). */ -export type WizardStepKind = +type WizardStepKind = | 'municipality-search' | 'votes' | 'trend-choice'