Merge pull request #120 from fsolla/cursor/pool-i106-d9499b1d-9029

B98 — Encadear ajustes após a ação principal do wizard
This commit is contained in:
Francisco Solla 2026-08-01 09:06:11 -03:00 committed by GitHub
commit f20fca0506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 397 additions and 82 deletions

View file

@ -1,6 +1,6 @@
# Encadear ajustes após a ação principal do wizard
Status: registrado
Status: em implementação
Atualizado em: 2026-08-01
Issue: #106
Priority: P1
@ -47,7 +47,7 @@ Dados: N/A como métrica nova — encadeia writes já existentes (votos, tendên
O rascunho UX-1 ([fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md)) pediu continuidade no fim de A1 (“Quer também…?: sinal / tendência / liderança”). Os wizards standalone existem (votos, sinal, tendência, liderança), com `entryAction` query **já** usada para skip (sinal/liderança) e prefill de nota de tendência — mas **ninguém navega** de um fluxo ao próximo após o save.
`WizardExpectedVotesStep` tem fase `saved` com placeholder: _“Próximo passo deste fluxo (sinal, tendência ou resumo) em breve.”_ — gancho explícito.
`WizardExpectedVotesStep` tinha fase `saved` com placeholder — B98 substitui por redirect imediato ao próximo elo.
Pedido (2026-08-01): ao iniciar uma ação → município → **ajuste principal** → **encadear** outros ajustes que façam sentido (ex. votos → tendência).
@ -79,7 +79,7 @@ Pedido (2026-08-01): ao iniciar uma ação → município → **ajuste principal
## Questões em aberto
- **Tela “Votos atualizados” intermediária some?** **Opções:** A pular direto à 1ª encadeada | B 1 toque “Continuar” na fase saved. **Recomendação:** **A** — menos atrito; Feel the action. _(assumido — validar no craft)_
- _(nenhuma — Option A da tela intermediária de votos fechada no craft 2026-08-01)_
## Abordagem proposta
@ -102,7 +102,7 @@ Componentes:
## Dependências
- Duras: **B96** (Pular nas encadeadas), **B97** (Salvar tendência não loopa). Soft: B61/B63/B64/B70 ✓, placeholder de votos.
- Duras: **B96** (Pular nas encadeadas) ✓ done/in-prod (#104), **B97** (Salvar tendência não loopa) ✓ done/in-prod (#105). Soft: B61/B63/B64/B70 ✓.
- Soft: OPS11 só para ver em prod.
## Não escopo
@ -123,11 +123,19 @@ Componentes:
- **Tela de resumo antes do fim.** Revisitar se CG reportar “não sei o que gravei”.
- **Cadeia a partir de sugestão E11.** Quando E11 deep-linkar wizards com município pré-escolhido.
## Freshness audit (2026-08-01)
- Arquivos do plano existem; `entryAction` / skip resolvers / href builders batem com o código.
- B96 (#104) e B97 (#105) `done`+`in-prod`.
- Skip ainda apontava para Início — B98 redefine para o próximo elo.
- Votos não recebia `entryAction` — wiring adicionado na page do wizard.
- **Tela intermediária de votos:** Option A (redirect direto) confirmada no craft.
## Referências
- [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md)
- `src/components/campaign/shared/WizardExpectedVotesStep.tsx` (fase `saved` + placeholder)
- `src/lib/campaignActionRoutes.ts` · `campaignWizardCopy.ts`
- `src/components/campaign/shared/WizardExpectedVotesStep.tsx`
- `src/lib/campaignActionRoutes.ts` · `campaignWizardCopy.ts` · `wizardActionChain.ts`
- [wizard-header-x-vs-pular.md](wizard-header-x-vs-pular.md) (B96) · [bug-salvar-tendencia-fecha-wizard.md](bug-salvar-tendencia-fecha-wizard.md) (B97)
- `PRODUCT.md` / `DESIGN.md`

View file

@ -38,6 +38,7 @@ import {
resolveWizardTrendNoteDestination,
} from '@/lib/politicalTrendWizardUi'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { isWizardChainActionId, wizardChainContinueHref } from '@/lib/wizardActionChain'
import config from '@/payload.config'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadWizardLeadershipTiles } from '@/utilities/leadership/leadershipData'
@ -129,6 +130,10 @@ export default async function CampaignActionWizardPage({
redirect(wizardActionHref(slug, municipalitySlug))
}
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
return (
<WizardExpectedVotesStep
actionSlug={slug}
@ -136,6 +141,7 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
entryAction={entryAction}
/>
)
}
@ -192,6 +198,10 @@ export default async function CampaignActionWizardPage({
const noteDestination = resolveWizardTrendNoteDestination(trendStatus, currentStatus)
if (noteDestination === 'home') {
// Chained session: advance the queue (B98). Standalone stale deep-link: Início (B97).
if (isWizardChainActionId(entryAction)) {
redirect(wizardChainContinueHref(entryAction, 'change-trend', municipalitySlug))
}
redirect(CAMPAIGN_HOME)
}

View file

@ -32,13 +32,13 @@ import {
WIZARD_LEADERSHIP_FORM_EDIT_TITLE,
WIZARD_LEADERSHIP_GRID_TITLE,
WIZARD_LEADERSHIP_SAVED_TOAST,
WIZARD_LEADERSHIP_SKIP_LABEL,
wizardFlowTitleForSlug,
} from '@/lib/campaignWizardCopy'
import { truncateNameAtWordBoundary } from '@/lib/leadershipNameTruncate'
import { cn } from '@/lib/utils'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
showLeadershipWizardSkip,
resolveWizardLeadershipSkip,
type WizardLeadershipTileViewModel,
} from '@/lib/wizardLeadershipContract'
@ -84,11 +84,12 @@ export const WizardLeadershipStep = ({
const [dirty, setDirty] = useState(false)
const [infoTile, setInfoTile] = useState<WizardLeadershipTileViewModel | null>(null)
const showSkip = showLeadershipWizardSkip(entryAction)
const skipConfig = showSkip
? { label: WIZARD_LEADERSHIP_SKIP_LABEL, href: CAMPAIGN_HOME }
: undefined
const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug)
const chainContinueHref = wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
)
const stepTitle =
mode.kind === 'grid'
@ -113,7 +114,7 @@ export const WizardLeadershipStep = ({
const handleContinue = () => {
startContinueTransition(() => {
router.push(CAMPAIGN_HOME)
router.replace(chainContinueHref)
})
}

View file

@ -14,10 +14,10 @@ import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardSignalHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { municipalitySignalTypeLabels } from '@/lib/schemas/municipalityUpdate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
resolveWizardSignalSkip,
WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX,
@ -46,12 +46,13 @@ export const WizardSignalBodyStep = ({
createMunicipalityListSignalFormAction,
{},
)
const skip = resolveWizardSignalSkip(entryAction)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug)
const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
router.push(CAMPAIGN_HOME)
const sessionEntry = resolveWizardChainEntry(entryAction, 'register-signal')
router.replace(wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug))
})
return (

View file

@ -42,7 +42,7 @@ export const WizardSignalTypeStep = ({
}: WizardSignalTypeStepProps) => {
const [infoType, setInfoType] = useState<MunicipalitySignalType | null>(null)
const infoEntry = infoType ? municipalitySignalTypeMetaByType[infoType] : null
const skip = resolveWizardSignalSkip(entryAction)
const skip = resolveWizardSignalSkip(entryAction, municipalitySlug)
return (
<>

View file

@ -49,7 +49,7 @@ export const WizardTrendChoiceStep = ({
prefillExtraParams,
}: WizardTrendChoiceStepProps) => {
const [infoEntry, setInfoEntry] = useState<PoliticalTrendWizardMetaEntry | null>(null)
const skip = resolveWizardTrendSkip(entryAction)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug)
const options = selectablePoliticalTrendStatuses(currentStatus)
return (

View file

@ -14,7 +14,6 @@ import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardTrendHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
resolveWizardTrendSkip,
@ -22,6 +21,7 @@ import {
WIZARD_TREND_SAVE_LABEL,
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
type WizardTrendNoteStepProps = {
@ -49,12 +49,13 @@ export const WizardTrendNoteStep = ({
setMunicipalityPoliticalTrendFormAction,
{},
)
const skip = resolveWizardTrendSkip(entryAction)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug)
const stepTitle = `Mudar tendência para ${politicalTrendLabels[trendStatus]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
router.replace(CAMPAIGN_HOME)
const sessionEntry = resolveWizardChainEntry(entryAction, 'change-trend')
router.replace(wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug))
})
return (

View file

@ -1,6 +1,8 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
@ -13,18 +15,20 @@ import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/Vot
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 { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
resolveWizardVotesSkip,
WIZARD_VOTES_FINAL_CTA_LABEL,
WIZARD_VOTES_SAVED_MESSAGE,
wizardFlowTitleForSlug,
wizardNextStepPlaceholder,
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
import {
applyVoteShortcut,
getWizardVoteViolation,
@ -40,6 +44,7 @@ type WizardExpectedVotesStepProps = {
municipalityName: string
municipalitySlug: string
initialExpectedVotes: VoteEstimateScenarioViewModel
entryAction?: CampaignWizardActionId
}
export const WizardExpectedVotesStep = ({
@ -48,13 +53,15 @@ export const WizardExpectedVotesStep = ({
municipalityName,
municipalitySlug,
initialExpectedVotes,
entryAction,
}: WizardExpectedVotesStepProps) => {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [estimates, setEstimates] = useState(initialExpectedVotes)
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
const [violation, setViolation] = useState<WizardVoteViolation | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [phase, setPhase] = useState<'editing' | 'saved'>('editing')
const skip = resolveWizardVotesSkip(entryAction, municipalitySlug)
const clearViolationState = () => {
if (violation) setViolation(null)
@ -71,6 +78,11 @@ export const WizardExpectedVotesStep = ({
handleValuesChange({ ...estimates, [focusedScenario]: nextValue })
}
const continueAfterVotes = () => {
const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes')
router.replace(wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug))
}
const handleConfirm = () => {
if (isPending) return
@ -103,30 +115,16 @@ export const WizardExpectedVotesStep = ({
setEstimates(payload.savedExpectedVotes)
recordLastActedMunicipality(municipalitySlug)
setPhase('saved')
toast.success(WIZARD_VOTES_SAVED_MESSAGE)
continueAfterVotes()
})
}
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">
<p className="text-sm text-muted-foreground">{WIZARD_VOTES_SAVED_MESSAGE}</p>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>
<Button asChild className="min-h-11 w-full sm:w-auto">
<CampaignWizardNavLink href={CAMPAIGN_HOME}>Voltar ao Início</CampaignWizardNavLink>
</Button>
</div>
</CampaignWizardShell>
)
}
const trailingAction = skip ? (
<Button variant="ghost" size="sm" className="min-h-11 px-2 text-sm" asChild>
<CampaignWizardNavLink href={skip.href}>{skip.label}</CampaignWizardNavLink>
</Button>
) : undefined
return (
<CampaignWizardShell
@ -136,6 +134,8 @@ export const WizardExpectedVotesStep = ({
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={trailingAction}
>
<div
className="flex flex-col gap-6"

View file

@ -1,8 +1,10 @@
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignWizardActionIdForSlug,
type CampaignWizardActionId,
} from '@/lib/campaignActionRoutes'
import { wizardFlowTitleForActionId } from '@/lib/campaignHomeActions'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const
@ -30,13 +32,35 @@ export const WIZARD_MUNICIPALITY_SEARCH_EMPTY = 'Nenhum município encontrado.'
export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as const
const WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER =
'Próximo passo deste fluxo (sinal, tendência ou resumo) em breve.' as const
const WIZARD_VOTES_SKIP_LABEL = 'Pular ajuste de votos →' as const
export const WIZARD_VOTES_FINAL_CTA_LABEL = 'Salvar estimativas →' as const
const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const
export type WizardVotesSkipAction = {
label: string
href: string
}
const shouldShowWizardVotesSkip = (entryAction: CampaignWizardActionId | undefined): boolean =>
entryAction != null && entryAction !== 'update-votes'
export const resolveWizardVotesSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
): WizardVotesSkipAction | undefined =>
shouldShowWizardVotesSkip(entryAction)
? {
label: WIZARD_VOTES_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-votes'),
'update-votes',
municipalitySlug,
),
}
: undefined
export const wizardNextStepTitle = (actionSlug: string): string => {
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
return 'Ajustar votos estimados'
@ -44,12 +68,8 @@ export const wizardNextStepTitle = (actionSlug: string): string => {
return 'Continuar'
}
export const wizardNextStepPlaceholder = (actionSlug: string): string => {
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
return WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER
}
return WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER
}
export const wizardNextStepPlaceholder = (_actionSlug: string): string =>
WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER
export const WIZARD_LEADERSHIP_GRID_TITLE = 'Quem coordena por aqui?' as const

View file

@ -1,10 +1,10 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import {
municipalitySignalTypeLabels,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
const politicalTrendDisplayLabels: Record<PoliticalTrendStatusValue, string> = {
favoravel: 'Favorável',
@ -69,9 +69,17 @@ export const shouldShowWizardTrendSkip = (
export const resolveWizardTrendSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
): WizardTrendSkipAction | undefined =>
shouldShowWizardTrendSkip(entryAction)
? { label: WIZARD_TREND_SKIP_LABEL, href: CAMPAIGN_HOME }
? {
label: WIZARD_TREND_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'change-trend'),
'change-trend',
municipalitySlug,
),
}
: undefined
export type PoliticalTrendNotePrefillSource =

View file

@ -0,0 +1,97 @@
/**
* B98 — ordered sub-flow queue after the principal wizard action succeeds.
* Pure navigation helpers; each link still writes via its existing action.
*/
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
type CampaignWizardActionId,
wizardActionHref,
wizardSignalHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
/** Actions that participate in the v1 adjustment chain (excludes demand). */
export type WizardChainActionId = Exclude<CampaignWizardActionId, 'register-demand'>
const WIZARD_CHAIN_AFTER = {
'update-votes': ['change-trend', 'register-signal', 'update-leadership'],
'register-signal': ['change-trend', 'update-votes', 'update-leadership'],
'change-trend': ['register-signal', 'update-votes', 'update-leadership'],
'update-leadership': ['register-signal', 'update-votes', 'change-trend'],
} as const satisfies Record<WizardChainActionId, readonly WizardChainActionId[]>
export const isWizardChainActionId = (
id: CampaignWizardActionId | undefined,
): id is WizardChainActionId => id != null && id in WIZARD_CHAIN_AFTER
/**
* Remaining chained steps after `completedAction` in a session owned by `entryAction`.
* When `completedAction` is the principal (or omitted), returns the full queue.
*/
export const wizardChainAfter = (
entryAction: WizardChainActionId,
completedAction?: WizardChainActionId,
): WizardChainActionId[] => {
const queue: WizardChainActionId[] = [...WIZARD_CHAIN_AFTER[entryAction]]
if (!completedAction || completedAction === entryAction) {
return queue
}
const index = queue.indexOf(completedAction)
if (index === -1) {
return []
}
return queue.slice(index + 1)
}
export const nextWizardChainStep = (
entryAction: WizardChainActionId,
completedAction: WizardChainActionId,
): WizardChainActionId | undefined => wizardChainAfter(entryAction, completedAction)[0]
const wizardHrefForChainStep = (
action: WizardChainActionId,
municipalitySlug: string,
entryAction: WizardChainActionId,
): string => {
const actionSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action]
switch (action) {
case 'update-votes':
case 'update-leadership':
return wizardActionHref(actionSlug, municipalitySlug, { entryAction })
case 'register-signal':
return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)
case 'change-trend':
return wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction)
default: {
const exhaustive: never = action
return exhaustive
}
}
}
/**
* Href for the next chained wizard after completing/skipping `completedAction`,
* or Início when the queue is empty / entry is not chainable.
*/
export const wizardChainContinueHref = (
entryAction: CampaignWizardActionId,
completedAction: WizardChainActionId,
municipalitySlug: string,
): string => {
if (!isWizardChainActionId(entryAction)) {
return CAMPAIGN_HOME
}
const next = nextWizardChainStep(entryAction, completedAction)
if (!next) {
return CAMPAIGN_HOME
}
return wizardHrefForChainStep(next, municipalitySlug, entryAction)
}
/** Session owner for navigation: explicit `entry` query, else the current principal. */
export const resolveWizardChainEntry = (
entryAction: CampaignWizardActionId | undefined,
currentAction: WizardChainActionId,
): WizardChainActionId => (isWizardChainActionId(entryAction) ? entryAction : currentAction)

View file

@ -1,5 +1,7 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { WIZARD_LEADERSHIP_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import type { SupportStatus } from '@/lib/schemas/leadership'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
/** Client-safe tile payload for the B70 leadership wizard grid. */
export type WizardLeadershipTileViewModel = {
@ -12,7 +14,27 @@ export type WizardLeadershipTileViewModel = {
notes: string | null
}
export type WizardLeadershipSkipAction = {
label: string
href: string
}
export const showLeadershipWizardSkip = (entryAction?: CampaignWizardActionId): boolean => {
const effectiveEntry = entryAction ?? 'update-leadership'
return effectiveEntry !== 'update-leadership'
}
export const resolveWizardLeadershipSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
): WizardLeadershipSkipAction | undefined =>
showLeadershipWizardSkip(entryAction)
? {
label: WIZARD_LEADERSHIP_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
),
}
: undefined

View file

@ -1,5 +1,5 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
export const WIZARD_SIGNAL_TYPE_STEP_TITLE = 'Que tipo de sinal?' as const
@ -22,7 +22,15 @@ export const shouldShowWizardSignalSkip = (
export const resolveWizardSignalSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
): WizardSignalSkipAction | undefined =>
shouldShowWizardSignalSkip(entryAction)
? { label: WIZARD_SIGNAL_SKIP_LABEL, href: CAMPAIGN_HOME }
? {
label: WIZARD_SIGNAL_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'register-signal'),
'register-signal',
municipalitySlug,
),
}
: undefined

View file

@ -219,6 +219,11 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
await step.getByRole('textbox', { name: 'Otimista' }).fill('350')
await page.getByRole('button', { name: 'Salvar estimativas →' }).click()
await page.waitForURL(
new RegExp(
`/campanha/acoes/mudar-tendencia\\?municipio=${municipality.slug}&entry=update-votes`,
),
)
await expect(page.getByText('Votos estimados atualizados.')).toBeVisible({ timeout: 15000 })
expect(page.url()).not.toContain('cenario=')
})
@ -273,7 +278,7 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
})
test.describe('Wizard — atualizar liderança (B70)', () => {
test('create leadership, save, and continue to home', async ({ campaign, page }) => {
test('create leadership, save, and continue into the chain', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -301,13 +306,17 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
await expect(page.getByRole('button', { name: 'Continuar' })).toBeVisible()
await page.getByRole('button', { name: 'Continuar' }).click()
await page.waitForURL('/campanha')
await expect(page.getByLabel('Buscar na campanha')).toBeVisible()
await page.waitForURL(
new RegExp(
`/campanha/acoes/registrar-sinal\\?municipio=${municipality.slug}&entry=update-leadership`,
),
)
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible()
})
})
test.describe('Wizard — registrar sinal (B63)', () => {
test('standalone flow: type grid, body step, save returns to Início', async ({
test('standalone flow: type grid, body step, save continues the chain', async ({
campaign,
page,
}) => {
@ -335,7 +344,11 @@ test.describe('Wizard — registrar sinal (B63)', () => {
await body.fill('Adversário marcou presença no centro do município.')
await page.getByRole('button', { name: 'Salvar' }).click()
await page.waitForURL(/\/campanha$/)
await page.waitForURL(
new RegExp(
`/campanha/acoes/mudar-tendencia\\?municipio=${municipality.slug}&entry=register-signal`,
),
)
await expect(page.getByText('Sinal registrado.')).toBeVisible()
})
@ -362,7 +375,7 @@ test.describe('Wizard — registrar sinal (B63)', () => {
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('embedded flow shows skip link', async ({ campaign, page }) => {
test('embedded flow shows skip link to the next chain step', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -374,8 +387,11 @@ test.describe('Wizard — registrar sinal (B63)', () => {
`/campanha/acoes/registrar-sinal?municipio=${municipality.slug}&entry=update-votes`,
)
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toBeVisible({
timeout: 15000,
})
const skip = page.getByRole('link', { name: 'Pular registro de sinal' })
await expect(skip).toBeVisible({ timeout: 15000 })
await expect(skip).toHaveAttribute(
'href',
`/campanha/acoes/atualizar-lideranca?municipio=${municipality.slug}&entry=update-votes`,
)
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import {
politicalTrendWizardMeta,
politicalTrendWizardMetaByStatus,
@ -39,16 +39,16 @@ describe('politicalTrendWizardUi', () => {
it('hides skip for standalone change-trend entry', () => {
expect(shouldShowWizardTrendSkip(undefined)).toBe(false)
expect(shouldShowWizardTrendSkip('change-trend')).toBe(false)
expect(resolveWizardTrendSkip(undefined)).toBeUndefined()
expect(resolveWizardTrendSkip('change-trend')).toBeUndefined()
expect(resolveWizardTrendSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardTrendSkip('change-trend', 'cairu')).toBeUndefined()
})
it('shows skip when embedded from another wizard action', () => {
it('shows skip to the next chain step when embedded from another wizard', () => {
expect(shouldShowWizardTrendSkip('update-votes')).toBe(true)
expect(shouldShowWizardTrendSkip('register-signal')).toBe(true)
expect(resolveWizardTrendSkip('update-votes')).toEqual({
expect(resolveWizardTrendSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_TREND_SKIP_LABEL,
href: CAMPAIGN_HOME,
href: `${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&entry=update-votes`,
})
})

View file

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
isWizardChainActionId,
nextWizardChainStep,
resolveWizardChainEntry,
wizardChainAfter,
wizardChainContinueHref,
} from '@/lib/wizardActionChain'
describe('wizardActionChain', () => {
it('excludes register-demand from the chainable set', () => {
expect(isWizardChainActionId('update-votes')).toBe(true)
expect(isWizardChainActionId('register-demand')).toBe(false)
expect(isWizardChainActionId(undefined)).toBe(false)
})
it('returns the full queue after the principal action', () => {
expect(wizardChainAfter('update-votes')).toEqual([
'change-trend',
'register-signal',
'update-leadership',
])
expect(wizardChainAfter('update-votes', 'update-votes')).toEqual([
'change-trend',
'register-signal',
'update-leadership',
])
expect(wizardChainAfter('register-signal', 'register-signal')).toEqual([
'change-trend',
'update-votes',
'update-leadership',
])
expect(wizardChainAfter('change-trend', 'change-trend')).toEqual([
'register-signal',
'update-votes',
'update-leadership',
])
expect(wizardChainAfter('update-leadership', 'update-leadership')).toEqual([
'register-signal',
'update-votes',
'change-trend',
])
})
it('slices the remaining queue after a chained step', () => {
expect(wizardChainAfter('update-votes', 'change-trend')).toEqual([
'register-signal',
'update-leadership',
])
expect(wizardChainAfter('update-votes', 'register-signal')).toEqual(['update-leadership'])
expect(wizardChainAfter('update-votes', 'update-leadership')).toEqual([])
expect(nextWizardChainStep('change-trend', 'register-signal')).toBe('update-votes')
expect(nextWizardChainStep('update-leadership', 'change-trend')).toBeUndefined()
})
it('builds continue hrefs with entry + municipio, or Início at the end', () => {
expect(wizardChainContinueHref('update-votes', 'update-votes', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`,
)
expect(wizardChainContinueHref('update-votes', 'change-trend', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&entry=update-votes`,
)
expect(wizardChainContinueHref('register-signal', 'change-trend', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-signal`,
)
expect(wizardChainContinueHref('change-trend', 'update-leadership', 'cairu')).toBe(
CAMPAIGN_HOME,
)
expect(wizardChainContinueHref('register-demand', 'update-votes', 'cairu')).toBe(CAMPAIGN_HOME)
})
it('resolves session entry from query or current principal', () => {
expect(resolveWizardChainEntry(undefined, 'update-votes')).toBe('update-votes')
expect(resolveWizardChainEntry('register-signal', 'update-votes')).toBe('register-signal')
expect(resolveWizardChainEntry('register-demand', 'change-trend')).toBe('change-trend')
})
})

View file

@ -3,12 +3,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
const replaceMock = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
replace: replaceMock,
}),
}))
@ -16,6 +19,10 @@ vi.mock('@/lib/campaignJsonRequest', () => ({
postCampaignJson: vi.fn(),
}))
vi.mock('sonner', () => ({
toast: { success: vi.fn() },
}))
const defaultProps = {
actionSlug: 'atualizar-votos',
municipalityId: 1,
@ -35,6 +42,7 @@ describe('WizardExpectedVotesStep', () => {
afterEach(() => {
cleanup()
vi.mocked(postCampaignJson).mockReset()
replaceMock.mockReset()
})
it('applies shortcuts to the focused scenario', () => {
@ -73,7 +81,7 @@ describe('WizardExpectedVotesStep', () => {
expect(screen.getByLabelText('Pessimista').getAttribute('aria-invalid')).toBeNull()
})
it('posts batch expected votes when estimates are coherent', async () => {
it('posts batch expected votes and continues the wizard chain', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: {
@ -97,7 +105,18 @@ describe('WizardExpectedVotesStep', () => {
})
await waitFor(() => {
expect(screen.getByRole('status').textContent).toMatch(/Votos estimados atualizados/i)
expect(replaceMock).toHaveBeenCalledWith(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`,
)
})
})
it('shows skip to the next chain step when votes is chained', () => {
renderVotesStep({ ...defaultProps, entryAction: 'register-signal' })
const skip = screen.getByRole('link', { name: /Pular ajuste de votos/i })
expect(skip.getAttribute('href')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=register-signal`,
)
})
})

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { showLeadershipWizardSkip } from '@/lib/wizardLeadershipContract'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { WIZARD_LEADERSHIP_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import {
resolveWizardLeadershipSkip,
showLeadershipWizardSkip,
} from '@/lib/wizardLeadershipContract'
describe('showLeadershipWizardSkip', () => {
it('hides skip for standalone entry (implicit update-leadership)', () => {
@ -15,3 +20,18 @@ describe('showLeadershipWizardSkip', () => {
expect(showLeadershipWizardSkip('update-votes')).toBe(true)
})
})
describe('resolveWizardLeadershipSkip', () => {
it('returns undefined for standalone entry', () => {
expect(resolveWizardLeadershipSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardLeadershipSkip('update-leadership', 'cairu')).toBeUndefined()
})
it('points skip to Início when leadership is the last chained step', () => {
// In the v1 matrix, leadership is always the final chained elo.
expect(resolveWizardLeadershipSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_LEADERSHIP_SKIP_LABEL,
href: CAMPAIGN_HOME,
})
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import {
municipalitySignalTypeMeta,
municipalitySignalTypeMetaByType,
@ -16,16 +16,20 @@ describe('wizardSignalUi', () => {
it('hides skip for standalone register-signal entry', () => {
expect(shouldShowWizardSignalSkip(undefined)).toBe(false)
expect(shouldShowWizardSignalSkip('register-signal')).toBe(false)
expect(resolveWizardSignalSkip(undefined)).toBeUndefined()
expect(resolveWizardSignalSkip('register-signal')).toBeUndefined()
expect(resolveWizardSignalSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardSignalSkip('register-signal', 'cairu')).toBeUndefined()
})
it('shows skip when embedded from another wizard action', () => {
it('shows skip to the next chain step when embedded from another wizard', () => {
expect(shouldShowWizardSignalSkip('update-votes')).toBe(true)
expect(shouldShowWizardSignalSkip('change-trend')).toBe(true)
expect(resolveWizardSignalSkip('update-votes')).toEqual({
expect(resolveWizardSignalSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_SIGNAL_SKIP_LABEL,
href: CAMPAIGN_HOME,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=update-votes`,
})
expect(resolveWizardSignalSkip('change-trend', 'cairu')).toEqual({
label: WIZARD_SIGNAL_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=change-trend`,
})
})
})