B98 — Encadear ajustes do wizard após a ação principal
Após salvar (ou Pular) votos/tendência/sinal/liderança, navega ao próximo elo da matriz v1 no mesmo município via entryAction, em vez de voltar ao Início. Helper puro wizardActionChain + skips alinhados. Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
7fbb63f9d4
commit
346949d035
18 changed files with 391 additions and 73 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ 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 +133,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 +144,7 @@ export default async function CampaignActionWizardPage({
|
|||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
|
||||
entryAction={entryAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -192,6 +201,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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,13 +32,16 @@ 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 {
|
||||
showLeadershipWizardSkip,
|
||||
resolveWizardChainEntry,
|
||||
wizardChainContinueHref,
|
||||
} from '@/lib/wizardActionChain'
|
||||
import {
|
||||
resolveWizardLeadershipSkip,
|
||||
type WizardLeadershipTileViewModel,
|
||||
} from '@/lib/wizardLeadershipContract'
|
||||
|
||||
|
|
@ -84,11 +87,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 +117,7 @@ export const WizardLeadershipStep = ({
|
|||
|
||||
const handleContinue = () => {
|
||||
startContinueTransition(() => {
|
||||
router.push(CAMPAIGN_HOME)
|
||||
router.replace(chainContinueHref)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@ 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 +49,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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,10 @@ 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 +52,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 (
|
||||
|
|
|
|||
|
|
@ -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,23 @@ 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 +47,7 @@ type WizardExpectedVotesStepProps = {
|
|||
municipalityName: string
|
||||
municipalitySlug: string
|
||||
initialExpectedVotes: VoteEstimateScenarioViewModel
|
||||
entryAction?: CampaignWizardActionId
|
||||
}
|
||||
|
||||
export const WizardExpectedVotesStep = ({
|
||||
|
|
@ -48,13 +56,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 +81,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 +118,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 +137,8 @@ export const WizardExpectedVotesStep = ({
|
|||
previousHref={wizardActionHref(actionSlug)}
|
||||
dismissHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
skip={skip}
|
||||
trailingAction={trailingAction}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col gap-6"
|
||||
|
|
|
|||
|
|
@ -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,36 @@ 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
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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 +69,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
98
src/lib/wizardActionChain.ts
Normal file
98
src/lib/wizardActionChain.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
82
tests/unit/wizardActionChain.unit.spec.ts
Normal file
82
tests/unit/wizardActionChain.unit.spec.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue