diff --git a/docs/plans/encadear-ajustes-wizard.md b/docs/plans/encadear-ajustes-wizard.md index 053778e5..3627e882 100644 --- a/docs/plans/encadear-ajustes-wizard.md +++ b/docs/plans/encadear-ajustes-wizard.md @@ -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` diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 11a460d0..3b6b2465 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -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 ( ) } @@ -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) } diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index 2c651ebe..6dcdfb52 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -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(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) }) } diff --git a/src/components/campaign/municipality/WizardSignalBodyStep.tsx b/src/components/campaign/municipality/WizardSignalBodyStep.tsx index 4a2a32c5..9deff264 100644 --- a/src/components/campaign/municipality/WizardSignalBodyStep.tsx +++ b/src/components/campaign/municipality/WizardSignalBodyStep.tsx @@ -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 ( diff --git a/src/components/campaign/municipality/WizardSignalTypeStep.tsx b/src/components/campaign/municipality/WizardSignalTypeStep.tsx index 258acafa..9af18f38 100644 --- a/src/components/campaign/municipality/WizardSignalTypeStep.tsx +++ b/src/components/campaign/municipality/WizardSignalTypeStep.tsx @@ -42,7 +42,7 @@ export const WizardSignalTypeStep = ({ }: WizardSignalTypeStepProps) => { const [infoType, setInfoType] = useState(null) const infoEntry = infoType ? municipalitySignalTypeMetaByType[infoType] : null - const skip = resolveWizardSignalSkip(entryAction) + const skip = resolveWizardSignalSkip(entryAction, municipalitySlug) return ( <> diff --git a/src/components/campaign/municipality/WizardTrendChoiceStep.tsx b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx index fed07511..d1ba83af 100644 --- a/src/components/campaign/municipality/WizardTrendChoiceStep.tsx +++ b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx @@ -49,7 +49,7 @@ export const WizardTrendChoiceStep = ({ prefillExtraParams, }: WizardTrendChoiceStepProps) => { const [infoEntry, setInfoEntry] = useState(null) - const skip = resolveWizardTrendSkip(entryAction) + const skip = resolveWizardTrendSkip(entryAction, municipalitySlug) const options = selectablePoliticalTrendStatuses(currentStatus) return ( diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx index fb7992cd..85f8c8d5 100644 --- a/src/components/campaign/municipality/WizardTrendNoteStep.tsx +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -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 ( diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index d08c6512..6a3bf77d 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -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('central') const [violation, setViolation] = useState(null) const [saveError, setSaveError] = useState(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 ( - -
-

{WIZARD_VOTES_SAVED_MESSAGE}

-

{wizardNextStepPlaceholder(actionSlug)}

- -
-
- ) - } + const trailingAction = skip ? ( + + ) : undefined return (
+ 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 diff --git a/src/lib/politicalTrendWizardUi.ts b/src/lib/politicalTrendWizardUi.ts index d4d81031..5c33dfae 100644 --- a/src/lib/politicalTrendWizardUi.ts +++ b/src/lib/politicalTrendWizardUi.ts @@ -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 = { 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 = diff --git a/src/lib/wizardActionChain.ts b/src/lib/wizardActionChain.ts new file mode 100644 index 00000000..bb68d083 --- /dev/null +++ b/src/lib/wizardActionChain.ts @@ -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 + +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 + +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) diff --git a/src/lib/wizardLeadershipContract.ts b/src/lib/wizardLeadershipContract.ts index e1958a62..7d936dbd 100644 --- a/src/lib/wizardLeadershipContract.ts +++ b/src/lib/wizardLeadershipContract.ts @@ -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 diff --git a/src/lib/wizardSignalUi.ts b/src/lib/wizardSignalUi.ts index 816fd1b9..d561af99 100644 --- a/src/lib/wizardSignalUi.ts +++ b/src/lib/wizardSignalUi.ts @@ -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 diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index 49d3a612..3f4d943b 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -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`, + ) }) }) diff --git a/tests/unit/politicalTrendWizardUi.unit.spec.ts b/tests/unit/politicalTrendWizardUi.unit.spec.ts index 7c9d0d12..699dd68e 100644 --- a/tests/unit/politicalTrendWizardUi.unit.spec.ts +++ b/tests/unit/politicalTrendWizardUi.unit.spec.ts @@ -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`, }) }) diff --git a/tests/unit/wizardActionChain.unit.spec.ts b/tests/unit/wizardActionChain.unit.spec.ts new file mode 100644 index 00000000..8ca05a10 --- /dev/null +++ b/tests/unit/wizardActionChain.unit.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx index d491e428..ab980709 100644 --- a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx +++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx @@ -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`, + ) + }) }) diff --git a/tests/unit/wizardLeadershipStep.unit.spec.ts b/tests/unit/wizardLeadershipStep.unit.spec.ts index 0942e9fd..d30de245 100644 --- a/tests/unit/wizardLeadershipStep.unit.spec.ts +++ b/tests/unit/wizardLeadershipStep.unit.spec.ts @@ -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, + }) + }) +}) diff --git a/tests/unit/wizardSignalUi.unit.spec.ts b/tests/unit/wizardSignalUi.unit.spec.ts index e3c58467..53eda322 100644 --- a/tests/unit/wizardSignalUi.unit.spec.ts +++ b/tests/unit/wizardSignalUi.unit.spec.ts @@ -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`, }) }) })