feat(campanha): B168 — desfazer encadeamento automático das ações rápidas do wizard

Ações de ajuste (votos, tendência, atualização, liderança) agora retornam à
origem ao salvar (toast + from allowlist B110 / Início), sem avançar sozinhas
para outro wizard. Removidos fila de encadeamento (wizardActionChain.ts),
param entry, skip/Pular (chrome mobile sempre X) e prefill de nota sem
produtor. Liderança: botão pós-save 'Continuar' → 'Concluir' (origem).
Sem migration.
This commit is contained in:
fsolla 2026-08-08 14:10:47 -03:00
parent b838d19447
commit 7faf3ed27f
29 changed files with 376 additions and 1104 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*<worktree>\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested).
**Recently resolved (2026-08-07):** **C15** (Issue #390) — `/campanha/agenda` virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; `/campanha/atividades` e giros permanecem como lista/fluxos legados. Sem migration. Impl [`fullcalendar-agenda-campanha-impl.md`](plans/fullcalendar-agenda-campanha-impl.md).

View file

@ -0,0 +1,81 @@
# Impl: Desfazer o encadeamento automático das ações rápidas do wizard
Status: aprovado
Atualizado em: 2026-08-08
Issue: #422
Intenção: docs/plans/desfazer-encadeamento-acoes-wizard.md
Appetite restante: herdado (~0,5–1 dia eng); sem migration
## Leitura da intenção
- **Outcome:** ao concluir **qualquer** ação rápida de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem — **nunca** avança sozinho para outro wizard. Chrome sem “Pular” em etapa nenhuma; “X” (sair da ação) em todas as etapas e retorna à origem. Fila encadeada, `entryAction` de cadeia e “próxima etapa sugerida” pós-save não existem mais.
- **O que NÃO negociar:** retorno à origem via `from` allowlist (**B110** — manter); wizards standalone intactos; liderança continua fora (já gateada); sem migration; sem redesenhos de chassis/header; sem mexer no catálogo/FAB/strip das ações.
- **O que reavaliar:** a “Direção no codebase” da intenção mira `wizardActionChain.ts` (matriz + continue/end), os skip resolvers e as pontas de sucesso. Confirmei e ampliei: a cadeia está **ativa em toda entrada**, não só via deep-link — `resolveWizardChainEntry(undefined, X) → X` faz TODO wizard salvo avançar para o próximo elo (o bug reportado acontece até partindo do card do Início, sem `entry=`). A cadeia é o único produtor de `entry=`. Conclusão: o caminho é **remoção total**, não “desativar pontas”.
## Abordagem recomendada
```mermaid
flowchart LR
A["wizardActionChain.ts<br/>(matriz + continue + entry)"] --> D["DELETAR"]
B["skip resolvers + WIZARD_CHAIN_SKIP_LABEL<br/>(4 libs)"] --> D
C["skip UI: Chrome state, MobileTopBar,<br/>Shell trailingAction, Wizard*SkipTrailing"] --> D
E["Fusão em campaignActionRoutes.ts:<br/>wizardReturnHref (ex chainEnd) +<br/>wizardPreviousHref (só passos internos)"] --> F["5 componentes + page.tsx:<br/>sucesso → router.push(wizardReturnHref)<br/>sem skips, sem entryAction"] --> G["Tests: unit + e2e"]
```
**Opções consideradas:** A (neutralizar sem limpar) | B (remoção total) | C (B + manter prefill `notePrefill`)
**Recomendação:** **B** — porque:
- A aceita de produto diz “limpeza total do plumbing/código morto é decisão de implementação” e “o resultado não pode ter 'Pular'/fila morta aparente”.
- A cadeia é o único produtor de `entry=`, `notePrefill`, `voteFrom`/`voteTo` (verifiquei: nenhuma registry passa esses params hoje). Manter o plumbing = código morto que o `knip`/`tsc` vão apontar + “dois sistemas de navegação” que a intenção proíbe.
- B é mecanicamente pequeno (módulos pequenos, call sites enumerados).
**Rejeitadas:** A porque deixa o módulo cadeia órfão e o smash do `skip` no chrome vivo (fila morta visível); C porque manter `notePrefill` sem produtor é especulação (adapter com 1 call site).
### Componentes / mudanças
- **`src/lib/wizardActionChain.ts`** → **deletar arquivo inteiro**. `wizardReturnHref` (ex `wizardChainEndHref`; allowlist B110 → senão `CAMPAIGN_HOME`) e `wizardPreviousHref` (só passos internos; cair a ramificação de elo da cadeia) migram para o módulo profundo que já constrói URIs de wizard — **`src/lib/campaignActionRoutes.ts`** (evita um `wizardNavigation.ts` raso; sem ciclo).
- **`src/lib/campaignActionRoutes.ts`**: remover `WIZARD_ENTRY_ACTION_QUERY_KEY`, `parseWizardEntryActionParam`, `WizardActionHrefOptions.entryAction`, param `entryAction` de `wizardTrendHref`, `WIZARD_NOTE_PREFILL_QUERY_KEY`, `WIZARD_VOTE_FROM_QUERY_KEY`, `WIZARD_VOTE_TO_QUERY_KEY`. `wizardTrendHref` final: `(actionSlug, municipalitySlug?, trendStatus?, returnPath?)`. **Manter**: `isWizardReturnPath`/`parseWizardReturnPath`/`appendWizardReturnPath` (B110), `campaignActionEntryHref`, `isCampaignWizardActionId`, `wizardActionHref` (`leadershipId`/`returnPath`), `parseWizardLeadershipIdParam`.
- **`wizardUpdateUi.ts` / `politicalTrendWizardUi.ts` / `wizardLeadershipContract.ts` / `campaignWizardCopy.ts`**: remover `*SkipAction`, `resolve*Skip`, `shouldShow*/show*`, `WIZARD_CHAIN_SKIP_LABEL`. Em `politicalTrendWizardUi` remover também `PoliticalTrendNotePrefillSource`/`buildPoliticalTrendNotePrefill`/`resolvePoliticalTrendNotePrefillSource` (sem produtor). **Manter** labels/títulos e `resolveWizardTrendNoteDestination` (redirect pós-save RSC).
- **Page `acoes/[slug]/page.tsx`**: remover parsing de `entry`/prefill; ramo `noteDestination === 'home'` simplifica para `redirect(wizardReturnHref(returnPath))`; drop de props `entryAction`/`prefillExtraParams`/`initialNote`.
- **Componentes**: `WizardExpectedVotesStep`, `WizardTrendNoteStep`, `WizardTrendChoiceStep`, `WizardUpdateBodyStep`, `WizardLeadershipStep` — drop `entryAction`+`skip`; sucesso → `router.push(wizardReturnHref(returnPath))`. `CampaignWizardShell` drop `skip`/`trailingAction`; `CampaignWizardChromeContext` drop `CampaignWizardChromeSkip`/campo `skip`; `CampaignMobileTopBar` **sempre** X (`chrome.skip` some). Deletar `WizardSkipTrailing.tsx` + `WizardTrendSkipTrailing.tsx`.
- **Liderança — botão flutuante pós-save:** destino → `wizardReturnHref(returnPath)`; **rótulo “Continuar” → “Concluir”** (opção A recomendada; ver Rabbit holes). `handleSaved` (grid + dirty) inalterado.
- **Migration:** sem migration (nenhuma mudança de schema).
- **Access / Consent:** nenhuma mudança.
- **UI:** Impeccable A/B — mudanças pontuais no chrome (remover skip) e copy; sem redesign.
## Fases verificáveis
1. **Libs + tests de domínio** — remoção em `campaignActionRoutes` + libs de wizard; `wizardActionChain.ts` → deletado (helpers fundidos); reescrever `wizardActionChain.unit.spec.ts` → `wizardNavigation`-equivalent em `campaignActionRoutes.unit.spec.ts`; atender `tests/unit` iterativamente.
2. **UI** — page + 5 steps + chrome (shell/context/mobile top bar); deletar skip components; testes de componente (`wizardExpectedVotesStep.unit.spec.tsx`) + e2e afetadas (`campaignHomeActions.e2e.spec.ts`, `campaignWizardChrome.e2e.spec.ts`).
3. **Gates** — `pnpm gate:fast` na iteração; `pnpm test:e2e:affected`; entrega com `pnpm push` (gate:ci).
## Rabbit holes / Não escopo (engenharia)
- Não redesenhos de chassis/header (nenhum “X” de desktop novo — hoje o desktop só usa o shell; mobile já tem X e passa a tê-lo sempre).
- Não mexer no catálogo/FAB/strip do Início nem nas registries de quick actions (nenhuma passa `entry` hoje).
- Não adicionar “continuar para a próxima” pós-save (rabbit hole de produto da intenção).
- Não reter `notePrefill`/`voteFrom`/`voteTo` como “feature futura” (nenhum produtor).
- **Decisão em aberto para o gate humano:** rótulo do botão flutuante de liderança — **A) “Concluir”** (recomendado: a ação terminou e sai) vs **B) manter “Continuar”** (menor churn, mas remete a encadeamento). Aplica-se também se o botão deve sumir (ver Riscos).
## Riscos e mitigação
- **Deep-link externo com `entry=` antigo** passa a se comportar como standalone (param ignorado) — sem guarda de URL pública; aceitável (params internos, sem contrato). E2E cobre com asserção “sem Pular mesmo com `entry=` stale”.
- **Prefill de nota de tendência some** — reduz superfície; hoje sem produtor. Se o produto quiser reabrir, vira issue nova.
- **E2E de tendência inexistente** — preciso adicionar “mudar tendência → salvar → volta à origem” (o próprio bug reportado). Custo de login; mantenho mínimo.
- **knip/tsc** — enumerei todas as referências de símbolos removidos (8 arquivos de teste + 7 src); verifico `git grep` antes de deletar.
- **Dupla navegação pós-save da tendência** (redirect servidor `noteDestination==='home'` + push client) converge para a MESMA origem — comportamento já existente hoje no standalone; manter.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (sem encadeamento, sem Pular, X em toda etapa, retorno à origem, B110 mantido)
- [x] Invariantes AGENTS/engineering-standards (sem migration; module-owner editado; copy pt-BR)
- [x] Testes de domínio previstos (unit `wizardReturnHref`/`wizardPreviousHref`/routes; componente votes; e2e retorno à origem para as 4 ações + tendência)
## Self-score decision-quality
1. Decisões caras têm rejeitadas? **Sim** (A/B/C explícitas, rótulo liderança aberto)
2. Cabe no appetite? **Sim** (remoção pequena, todo call site enumerado)
3. Rabbit holes nomeados? **Sim**
4. Depth check reusa shells/helpers? **Sim** (funde no módulo profundo de routes; zero módulos novos rasos)
5. Intenção satisfeita? **Sim** — engenharia remove o mecanismo, não reescreve o outcome.
**Score: 5/5**

View file

@ -11,62 +11,28 @@ import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
hasWizardScenarioParam,
isCampaignWizardActionSlug,
parseWizardEntryActionParam,
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
parseWizardReturnPath,
resolveWizardTrendStatusParam,
WIZARD_ENTRY_ACTION_QUERY_KEY,
WIZARD_LEADERSHIP_ID_QUERY_KEY,
WIZARD_MUNICIPIO_QUERY_KEY,
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_RETURN_PATH_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
WIZARD_TREND_STATUS_QUERY_KEY,
WIZARD_VOTE_FROM_QUERY_KEY,
WIZARD_VOTE_TO_QUERY_KEY,
wizardActionHref,
wizardReturnHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
buildPoliticalTrendNotePrefill,
resolvePoliticalTrendNotePrefillSource,
resolveWizardTrendNoteDestination,
} from '@/lib/politicalTrendWizardUi'
import { resolveWizardTrendNoteDestination } from '@/lib/politicalTrendWizardUi'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
isWizardChainActionId,
wizardChainContinueHref,
wizardChainEndHref,
} from '@/lib/wizardActionChain'
import config from '@/payload.config'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadWizardLeadershipTiles } from '@/utilities/leadership/leadershipData'
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
import { getPayload } from 'payload'
const politicalTrendWizardPrefillParams = (
searchParams: Record<string, string | string[] | undefined>,
): Record<string, string> | undefined => {
const keys = [
WIZARD_NOTE_PREFILL_QUERY_KEY,
WIZARD_VOTE_FROM_QUERY_KEY,
WIZARD_VOTE_TO_QUERY_KEY,
] as const
const result: Record<string, string> = {}
for (const key of keys) {
const raw = searchParams[key]
const value = Array.isArray(raw) ? raw[0] : raw
if (value?.trim()) {
result[key] = value.trim()
}
}
return Object.keys(result).length > 0 ? result : undefined
}
type CampaignActionWizardPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -104,7 +70,7 @@ export default async function CampaignActionWizardPage({
return (
<WizardMunicipalitySearchStep
actionSlug={slug}
previousHref={wizardChainEndHref(returnPath)}
previousHref={wizardReturnHref(returnPath)}
returnPath={returnPath}
accessibleMunicipalities={accessibleMunicipalities}
/>
@ -131,10 +97,6 @@ export default async function CampaignActionWizardPage({
redirect(wizardActionHref(slug, municipalitySlug))
}
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
return (
<WizardExpectedVotesStep
actionSlug={slug}
@ -142,16 +104,12 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
entryAction={entryAction}
returnPath={returnPath}
/>
)
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-leadership']) {
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
const initialLeadershipId = parseWizardLeadershipIdParam(
resolvedSearchParams[WIZARD_LEADERSHIP_ID_QUERY_KEY],
)
@ -163,7 +121,6 @@ export default async function CampaignActionWizardPage({
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
entryAction={entryAction}
initialTiles={tiles}
initialLeadershipId={initialLeadershipId}
returnPath={returnPath}
@ -172,43 +129,25 @@ export default async function CampaignActionWizardPage({
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['change-trend']) {
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
const prefillExtraParams = politicalTrendWizardPrefillParams(resolvedSearchParams)
const prefillSource = resolvePoliticalTrendNotePrefillSource({
entryAction,
notePrefill: resolvedSearchParams[WIZARD_NOTE_PREFILL_QUERY_KEY],
voteFrom: resolvedSearchParams[WIZARD_VOTE_FROM_QUERY_KEY],
voteTo: resolvedSearchParams[WIZARD_VOTE_TO_QUERY_KEY],
})
const initialNote = buildPoliticalTrendNotePrefill(prefillSource)
const currentStatus = municipality.politicalTrend?.status ?? null
const { trendStatus, invalid } = resolveWizardTrendStatusParam(
resolvedSearchParams[WIZARD_TREND_STATUS_QUERY_KEY],
)
if (invalid) {
redirect(wizardTrendHref(slug, municipalitySlug, undefined, entryAction, prefillExtraParams))
redirect(wizardTrendHref(slug, municipalitySlug))
}
if (trendStatus) {
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, returnPath),
)
}
redirect(wizardChainEndHref(returnPath))
}
// Post-save RSC refresh (status already matches) lands in 'home': render
// the note step and let the client own the toast + return navigation
// (B97 / B168) — a server redirect here would swallow the toast. The
// choice tiles only ever link selectable (≠ current) statuses, so a fresh
// deep-link cannot realistically reach 'home'.
if (noteDestination === 'choice') {
redirect(
wizardTrendHref(slug, municipalitySlug, undefined, entryAction, prefillExtraParams),
)
redirect(wizardTrendHref(slug, municipalitySlug))
}
return (
@ -218,8 +157,6 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
trendStatus={trendStatus}
initialNote={initialNote}
entryAction={entryAction}
returnPath={returnPath}
/>
)
@ -231,25 +168,18 @@ export default async function CampaignActionWizardPage({
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
currentStatus={currentStatus}
entryAction={entryAction}
prefillExtraParams={prefillExtraParams}
returnPath={returnPath}
/>
)
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-update']) {
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
return (
<WizardUpdateBodyStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
entryAction={entryAction}
returnPath={returnPath}
isStaff={isStaffCampaignRole(user.role)}
/>

View file

@ -7,7 +7,6 @@ import { toast } from 'sonner'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { WizardLeadershipForm } from '@/components/campaign/leadership/WizardLeadershipForm'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -19,11 +18,11 @@ import {
DrawerTitle,
} from '@/components/ui/Drawer'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import {
WIZARD_LEADERSHIP_ADD_TILE_LABEL,
WIZARD_LEADERSHIP_CONTINUE_LABEL,
WIZARD_LEADERSHIP_CONCLUDE_LABEL,
WIZARD_LEADERSHIP_EMPTY_GRID,
WIZARD_LEADERSHIP_EMPTY_NOTES,
WIZARD_LEADERSHIP_FORM_CREATE_TITLE,
@ -34,23 +33,13 @@ import {
} from '@/lib/campaignWizardCopy'
import { truncateNameAtWordBoundary } from '@/lib/leadershipNameTruncate'
import { cn } from '@/lib/utils'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardLeadershipSkip,
type WizardLeadershipTileViewModel,
} from '@/lib/wizardLeadershipContract'
import { type WizardLeadershipTileViewModel } from '@/lib/wizardLeadershipContract'
type WizardLeadershipStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
initialTiles: WizardLeadershipTileViewModel[]
initialLeadershipId?: number
returnPath?: string
@ -71,7 +60,6 @@ export const WizardLeadershipStep = ({
municipalityId,
municipalityName,
municipalitySlug,
entryAction,
initialTiles,
initialLeadershipId,
returnPath,
@ -89,14 +77,6 @@ export const WizardLeadershipStep = ({
const [dirty, setDirty] = useState(false)
const [infoTile, setInfoTile] = useState<WizardLeadershipTileViewModel | null>(null)
const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug, returnPath)
const chainContinueHref = wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
returnPath,
)
const stepTitle =
mode.kind === 'grid'
? WIZARD_LEADERSHIP_GRID_TITLE
@ -104,12 +84,6 @@ export const WizardLeadershipStep = ({
? WIZARD_LEADERSHIP_FORM_EDIT_TITLE
: WIZARD_LEADERSHIP_FORM_CREATE_TITLE
const trailingAction = skipConfig ? (
<Button variant="ghost" size="sm" className="min-h-11 px-2 text-sm" asChild>
<CampaignWizardNavLink href={skipConfig.href}>{skipConfig.label}</CampaignWizardNavLink>
</Button>
) : undefined
const handleSaved = useCallback(() => {
recordLastActedMunicipality(municipalitySlug)
setDirty(true)
@ -120,7 +94,7 @@ export const WizardLeadershipStep = ({
const handleContinue = () => {
startContinueTransition(() => {
router.push(chainContinueHref)
router.push(wizardReturnHref(returnPath))
})
}
@ -133,13 +107,10 @@ export const WizardLeadershipStep = ({
actionSlug,
stepKind: mode.kind === 'form' ? 'leadership-form' : 'leadership-grid',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
skip={skipConfig}
trailingAction={trailingAction}
contentFocus={mode.kind === 'form' ? 'none' : 'title'}
>
{mode.kind === 'form' ? (
@ -184,7 +155,7 @@ export const WizardLeadershipStep = ({
{dirty && mode.kind === 'grid' ? (
<div className="fixed bottom-4 right-4 z-20 pb-[max(0px,env(safe-area-inset-bottom))]">
<Button className="min-h-11 shadow-md" onClick={handleContinue} disabled={isContinuing}>
{WIZARD_LEADERSHIP_CONTINUE_LABEL}
{WIZARD_LEADERSHIP_CONCLUDE_LABEL}
</Button>
</div>
) : null}

View file

@ -1,13 +0,0 @@
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { Button } from '@/components/ui/button'
import type { WizardUpdateSkipAction } from '@/lib/wizardUpdateUi'
type WizardSkipTrailingProps = {
skip: WizardUpdateSkipAction
}
export const WizardSkipTrailing = ({ skip }: WizardSkipTrailingProps) => (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<CampaignWizardNavLink href={skip.href}>{skip.label}</CampaignWizardNavLink>
</Button>
)

View file

@ -3,7 +3,6 @@
import { Info } from 'lucide-react'
import { useState } from 'react'
import { WizardTrendSkipTrailing } from '@/components/campaign/municipality/WizardTrendSkipTrailing'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Button } from '@/components/ui/button'
@ -16,29 +15,24 @@ import {
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardTrendHref } from '@/lib/campaignActionRoutes'
import { wizardPreviousHref, wizardReturnHref, wizardTrendHref } from '@/lib/campaignActionRoutes'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
politicalTrendWizardMetaByStatus,
type PoliticalTrendWizardMetaEntry,
} from '@/lib/politicalTrendWizardMeta'
import {
resolveWizardTrendSkip,
selectablePoliticalTrendStatuses,
wizardTrendChoiceStepTitle,
} from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { cn } from '@/lib/utils'
import { wizardChainEndHref, wizardPreviousHref } from '@/lib/wizardActionChain'
type WizardTrendChoiceStepProps = {
actionSlug: string
municipalityName: string
municipalitySlug: string
currentStatus: PoliticalTrendStatusValue | null
entryAction?: CampaignWizardActionId
prefillExtraParams?: Record<string, string>
returnPath?: string
}
@ -47,12 +41,9 @@ export const WizardTrendChoiceStep = ({
municipalityName,
municipalitySlug,
currentStatus,
entryAction,
prefillExtraParams,
returnPath,
}: WizardTrendChoiceStepProps) => {
const [infoEntry, setInfoEntry] = useState<PoliticalTrendWizardMetaEntry | null>(null)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug, returnPath)
const options = selectablePoliticalTrendStatuses(currentStatus)
return (
@ -65,13 +56,10 @@ export const WizardTrendChoiceStep = ({
actionSlug,
stepKind: 'trend-choice',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardTrendSkipTrailing skip={skip} /> : undefined}
>
<ul className="grid list-none grid-cols-2 gap-3 md:grid-cols-3">
{options.map((status) => {
@ -80,14 +68,7 @@ export const WizardTrendChoiceStep = ({
return (
<li key={status} className="relative">
<CampaignWizardNavLink
href={wizardTrendHref(
actionSlug,
municipalitySlug,
status,
entryAction,
prefillExtraParams,
returnPath,
)}
href={wizardTrendHref(actionSlug, municipalitySlug, status, returnPath)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border bg-background p-3 pr-10 text-left',
entry.tileClassName,

View file

@ -4,28 +4,17 @@ import { useRouter } from 'next/navigation'
import { useActionState, useState } from 'react'
import { setMunicipalityPoliticalTrendFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
import { WizardTrendSkipTrailing } from '@/components/campaign/municipality/WizardTrendSkipTrailing'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import {
resolveWizardTrendSkip,
WIZARD_TREND_CLEAR_LABEL,
WIZARD_TREND_SAVE_LABEL,
} from '@/lib/politicalTrendWizardUi'
import { WIZARD_TREND_CLEAR_LABEL, WIZARD_TREND_SAVE_LABEL } from '@/lib/politicalTrendWizardUi'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
type WizardTrendNoteStepProps = {
@ -34,8 +23,6 @@ type WizardTrendNoteStepProps = {
municipalityName: string
municipalitySlug: string
trendStatus: PoliticalTrendStatusValue
initialNote: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
@ -45,23 +32,19 @@ export const WizardTrendNoteStep = ({
municipalityName,
municipalitySlug,
trendStatus,
initialNote,
entryAction,
returnPath,
}: WizardTrendNoteStepProps) => {
const router = useRouter()
const [note, setNote] = useState(initialNote)
const [note, setNote] = useState('')
const [state, submitAction, isPending] = useActionState(
setMunicipalityPoliticalTrendFormAction,
{},
)
const skip = resolveWizardTrendSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = `Mudar tendência para ${politicalTrendLabels[trendStatus]}`
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'change-trend')
router.push(wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath))
router.push(wizardReturnHref(returnPath))
})
return (
@ -73,13 +56,10 @@ export const WizardTrendNoteStep = ({
actionSlug,
stepKind: 'trend-note',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardTrendSkipTrailing skip={skip} /> : undefined}
contentFocus="none"
>
<form

View file

@ -1,13 +0,0 @@
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { Button } from '@/components/ui/button'
import type { WizardTrendSkipAction } from '@/lib/politicalTrendWizardUi'
type WizardTrendSkipTrailingProps = {
skip: WizardTrendSkipAction
}
export const WizardTrendSkipTrailing = ({ skip }: WizardTrendSkipTrailingProps) => (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<CampaignWizardNavLink href={skip.href}>{skip.label}</CampaignWizardNavLink>
</Button>
)

View file

@ -4,7 +4,6 @@ import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { createMunicipalityUpdateFormAction } from '@/app/(campaign)/campanha/(app)/municipios/[slug]/updateFormActions'
import { WizardSkipTrailing } from '@/components/campaign/municipality/WizardSkipTrailing'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
@ -14,28 +13,17 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
resolveWizardUpdateSkip,
WIZARD_UPDATE_BODY_STEP_TITLE,
WIZARD_UPDATE_SAVE_LABEL,
} from '@/lib/wizardUpdateUi'
import { WIZARD_UPDATE_BODY_STEP_TITLE, WIZARD_UPDATE_SAVE_LABEL } from '@/lib/wizardUpdateUi'
type WizardUpdateBodyStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
returnPath?: string
isStaff: boolean
}
@ -45,21 +33,16 @@ export const WizardUpdateBodyStep = ({
municipalityId,
municipalityName,
municipalitySlug,
entryAction,
returnPath,
isStaff,
}: WizardUpdateBodyStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(createMunicipalityUpdateFormAction, {})
const skip = resolveWizardUpdateSkip(entryAction, municipalitySlug, returnPath)
const stepTitle = WIZARD_UPDATE_BODY_STEP_TITLE
useCampaignFormSuccessToast(state, () => {
recordLastActedMunicipality(municipalitySlug)
const sessionEntry = resolveWizardChainEntry(entryAction, 'register-update')
router.push(
wizardChainContinueHref(sessionEntry, 'register-update', municipalitySlug, returnPath),
)
router.push(wizardReturnHref(returnPath))
})
return (
@ -71,13 +54,10 @@ export const WizardUpdateBodyStep = ({
actionSlug,
stepKind: 'update-body',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardSkipTrailing skip={skip} /> : undefined}
contentFocus="none"
>
<form

View file

@ -6,7 +6,6 @@ import { CampaignListResults } from '@/components/campaign/shared/CampaignListPe
import {
toCampaignWizardChromeState,
useSetCampaignWizardChrome,
type CampaignWizardChromeSkip,
} from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { WIZARD_STEP_PENDING_MESSAGE, wizardFlowChromeAriaLabel } from '@/lib/campaignWizardCopy'
@ -19,8 +18,6 @@ export type CampaignWizardShellProps = {
previousHref: string
dismissHref?: string
municipalityLabel?: string
skip?: CampaignWizardChromeSkip
trailingAction?: ReactNode
contentAlign?: 'start' | 'end'
/** When `'none'`, the shell does not move focus to the step title — use on form steps that autofocus an input. */
contentFocus?: 'title' | 'none'
@ -34,8 +31,6 @@ export const CampaignWizardShell = ({
previousHref,
dismissHref = CAMPAIGN_HOME,
municipalityLabel,
skip,
trailingAction,
contentAlign = 'start',
contentFocus = 'title',
children,
@ -52,9 +47,8 @@ export const CampaignWizardShell = ({
previousHref,
dismissHref,
municipalityLabel,
skip,
}),
[dismissHref, flowTitle, isEntryStep, municipalityLabel, previousHref, skip],
[dismissHref, flowTitle, isEntryStep, municipalityLabel, previousHref],
)
useSetCampaignWizardChrome(chrome)
@ -66,8 +60,6 @@ export const CampaignWizardShell = ({
return (
<div className="flex min-h-full w-full flex-col">
{trailingAction ? <div className="hidden justify-end md:flex">{trailingAction}</div> : null}
<main
aria-labelledby={hasStepTitle ? titleId : undefined}
aria-label={hasStepTitle ? undefined : wizardFlowChromeAriaLabel(flowTitle)}

View file

@ -9,29 +9,21 @@ import {
MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
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 { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import {
resolveWizardVotesSkip,
WIZARD_VOTES_FINAL_CTA_LABEL,
WIZARD_VOTES_SAVED_MESSAGE,
wizardFlowTitleForSlug,
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
resolveWizardChainEntry,
wizardChainContinueHref,
wizardChainEndHref,
wizardPreviousHref,
} from '@/lib/wizardActionChain'
import {
applyVoteShortcut,
getWizardVoteViolation,
@ -47,7 +39,6 @@ type WizardExpectedVotesStepProps = {
municipalityName: string
municipalitySlug: string
initialExpectedVotes: VoteEstimateScenarioViewModel
entryAction?: CampaignWizardActionId
returnPath?: string
}
@ -57,7 +48,6 @@ export const WizardExpectedVotesStep = ({
municipalityName,
municipalitySlug,
initialExpectedVotes,
entryAction,
returnPath,
}: WizardExpectedVotesStepProps) => {
const router = useRouter()
@ -66,7 +56,6 @@ export const WizardExpectedVotesStep = ({
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
const [violation, setViolation] = useState<WizardVoteViolation | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const skip = resolveWizardVotesSkip(entryAction, municipalitySlug, returnPath)
const clearViolationState = () => {
if (violation) setViolation(null)
@ -84,8 +73,7 @@ export const WizardExpectedVotesStep = ({
}
const continueAfterVotes = () => {
const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes')
router.push(wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath))
router.push(wizardReturnHref(returnPath))
}
const handleConfirm = () => {
@ -125,12 +113,6 @@ export const WizardExpectedVotesStep = ({
})
}
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
flowTitle={wizardFlowTitleForSlug(actionSlug)}
@ -140,13 +122,10 @@ export const WizardExpectedVotesStep = ({
actionSlug,
stepKind: 'votes',
municipalitySlug,
entryAction,
returnPath,
})}
dismissHref={wizardChainEndHref(returnPath)}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
skip={skip}
trailingAction={trailingAction}
contentFocus="none"
>
<div

View file

@ -1,11 +1,10 @@
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { wizardActionHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
import {
wizardFlowTitleForSlug,
wizardNextStepPlaceholder,
wizardNextStepTitle,
} from '@/lib/campaignWizardCopy'
import { wizardChainEndHref } from '@/lib/wizardActionChain'
type WizardMunicipalitySelectedStubProps = {
actionSlug: string
@ -23,7 +22,7 @@ export const WizardMunicipalitySelectedStub = ({
stepTitle={wizardNextStepTitle(actionSlug)}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug, undefined, { returnPath })}
dismissHref={wizardChainEndHref(returnPath)}
dismissHref={wizardReturnHref(returnPath)}
municipalityLabel={municipalityName}
>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>

View file

@ -26,9 +26,6 @@ const wizardNavButtonClass =
const wizardDismissButtonClass =
'size-11 shrink-0 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground'
const wizardSkipButtonClass =
'min-h-11 max-w-[9rem] px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground'
const wizardTitleSkeletonClass = 'mx-auto motion-reduce:animate-none bg-primary-foreground/20'
export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: ReactNode }) => {
@ -90,34 +87,17 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
</div>
<div className="flex shrink-0 items-center">
{chrome.skip ? (
<Button
variant="ghost"
size="sm"
className={wizardSkipButtonClass}
asChild
disabled={isPending}
>
<CampaignWizardNavLink href={chrome.skip.href} title={chrome.skip.label}>
{chrome.skip.label}
</CampaignWizardNavLink>
</Button>
) : (
<Button
variant="ghost"
size="icon"
className={wizardDismissButtonClass}
asChild
disabled={isPending}
>
<CampaignWizardNavLink
href={chrome.dismissHref}
aria-label={WIZARD_DISMISS_ARIA_LABEL}
>
<X className="size-5" aria-hidden />
</CampaignWizardNavLink>
</Button>
)}
<Button
variant="ghost"
size="icon"
className={wizardDismissButtonClass}
asChild
disabled={isPending}
>
<CampaignWizardNavLink href={chrome.dismissHref} aria-label={WIZARD_DISMISS_ARIA_LABEL}>
<X className="size-5" aria-hidden />
</CampaignWizardNavLink>
</Button>
</div>
</header>
)

View file

@ -11,18 +11,12 @@ import {
type SetStateAction,
} from 'react'
export type CampaignWizardChromeSkip = {
label: string
href: string
}
export type CampaignWizardChromeState = {
flowTitle: string
municipalityLabel?: string
stepKind: 'entry' | 'continue'
previousHref?: string
dismissHref: string
skip?: CampaignWizardChromeSkip
}
type CampaignWizardChromeContextValue = {
@ -60,7 +54,6 @@ type CampaignWizardChromeInput = {
previousHref: string
dismissHref: string
municipalityLabel?: string
skip?: CampaignWizardChromeSkip
}
export const toCampaignWizardChromeState = ({
@ -69,14 +62,12 @@ export const toCampaignWizardChromeState = ({
previousHref,
dismissHref,
municipalityLabel,
skip,
}: CampaignWizardChromeInput): CampaignWizardChromeState => ({
flowTitle,
municipalityLabel,
stepKind: isEntryStep ? 'entry' : 'continue',
previousHref: isEntryStep ? undefined : previousHref,
dismissHref,
skip,
})
export const useSetCampaignWizardChrome = (state: CampaignWizardChromeState | null): void => {

View file

@ -1,5 +1,6 @@
/** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
parsePoliticalTrendStatusFormValue,
type PoliticalTrendStatusValue,
@ -47,16 +48,8 @@ export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
/** Legacy B61 ritual param — canonical URLs omit it (B77). */
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
export const WIZARD_ENTRY_ACTION_QUERY_KEY = 'entry' as const
export const WIZARD_TREND_STATUS_QUERY_KEY = 'trendStatus' as const
export const WIZARD_NOTE_PREFILL_QUERY_KEY = 'notePrefill' as const
export const WIZARD_VOTE_FROM_QUERY_KEY = 'voteFrom' as const
export const WIZARD_VOTE_TO_QUERY_KEY = 'voteTo' as const
export const WIZARD_LEADERSHIP_ID_QUERY_KEY = 'leadershipId' as const
export const WIZARD_RETURN_PATH_QUERY_KEY = 'from' as const
@ -68,7 +61,7 @@ const WIZARD_RETURN_PATH_AUTH_PREFIXES = [
'/campanha/webauthn',
] as const
/** Allowlisted internal return paths for wizard dismiss / chain end (B110). */
/** Allowlisted internal return paths for wizard dismiss / return (B110). */
export const isWizardReturnPath = (pathname: string): boolean => {
if (!pathname.startsWith('/campanha')) return false
if (pathname.startsWith('/campanha/acoes')) return false
@ -98,7 +91,6 @@ export const appendWizardReturnPath = (href: string, returnPath?: string): strin
}
export type WizardActionHrefOptions = {
entryAction?: CampaignWizardActionId
leadershipId?: number
returnPath?: string
}
@ -109,16 +101,13 @@ export const wizardActionHref = (
options?: WizardActionHrefOptions,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug && !options?.leadershipId && !options?.entryAction) {
if (!municipalitySlug && options?.leadershipId === undefined) {
return appendWizardReturnPath(base, options?.returnPath)
}
const params = new URLSearchParams()
if (municipalitySlug) {
params.set(WIZARD_MUNICIPIO_QUERY_KEY, municipalitySlug)
}
if (options?.entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, options.entryAction)
}
if (options?.leadershipId !== undefined) {
params.set(WIZARD_LEADERSHIP_ID_QUERY_KEY, String(options.leadershipId))
}
@ -148,17 +137,6 @@ export const parseWizardLeadershipIdParam = (
export const hasWizardScenarioParam = (value: string | string[] | undefined): boolean =>
parseWizardMunicipioParam(value) !== undefined
export const parseWizardEntryActionParam = (
value: string | string[] | undefined,
): CampaignWizardActionId | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed || !isCampaignWizardActionId(trimmed)) {
return undefined
}
return trimmed
}
export const resolveWizardTrendStatusParam = (
value: string | string[] | undefined,
): { trendStatus?: PoliticalTrendStatusValue; invalid: boolean } => {
@ -180,8 +158,6 @@ export const wizardTrendHref = (
actionSlug: string,
municipalitySlug?: string,
trendStatus?: PoliticalTrendStatusValue,
entryAction?: CampaignWizardActionId,
extraParams?: Record<string, string>,
returnPath?: string,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
@ -193,13 +169,51 @@ export const wizardTrendHref = (
if (trendStatus) {
params.set(WIZARD_TREND_STATUS_QUERY_KEY, trendStatus)
}
if (entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction)
}
if (extraParams) {
for (const [key, value] of Object.entries(extraParams)) {
if (value) params.set(key, value)
}
}
return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath)
}
/** Dismiss / return target for wizard steps: allowlisted `from`, else Início (B110). */
export const wizardReturnHref = (returnPath?: string): string =>
returnPath && isWizardReturnPath(returnPath) ? returnPath : CAMPAIGN_HOME
/**
* Principal wizard steps (register-update, update-votes, change-trend,
* update-leadership) share the municipality-search route for "back".
*/
type WizardStepKind =
| 'municipality-search'
| 'votes'
| 'trend-choice'
| 'trend-note'
| 'update-body'
| 'leadership-grid'
| 'leadership-form'
type WizardPreviousHrefInput = {
actionSlug: string
stepKind: WizardStepKind
municipalitySlug?: string
returnPath?: string
}
/**
* Href for the logically previous wizard step — internal sub-steps only. Every
* principal wizard step goes back to the municipality search of its own flow.
*/
export const wizardPreviousHref = (input: WizardPreviousHrefInput): string => {
const { actionSlug, stepKind, municipalitySlug, returnPath } = input
if (stepKind === 'municipality-search') {
return wizardReturnHref(returnPath)
}
if (stepKind === 'trend-note') {
return wizardTrendHref(actionSlug, municipalitySlug, undefined, returnPath)
}
if (stepKind === 'leadership-form') {
return wizardActionHref(actionSlug, municipalitySlug, { returnPath })
}
return wizardActionHref(actionSlug, undefined, { returnPath })
}

View file

@ -1,10 +1,8 @@
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
@ -32,37 +30,10 @@ export const WIZARD_MUNICIPALITY_SEARCH_EMPTY = 'Nenhum município encontrado.'
export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as const
export const WIZARD_CHAIN_SKIP_LABEL = 'Pular' 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,
returnPath?: string,
): WizardVotesSkipAction | undefined =>
shouldShowWizardVotesSkip(entryAction)
? {
label: WIZARD_CHAIN_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-votes'),
'update-votes',
municipalitySlug,
returnPath,
),
}
: undefined
export const wizardNextStepTitle = (actionSlug: string): string => {
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
return 'Ajustar votos estimados'
@ -79,7 +50,7 @@ export const WIZARD_LEADERSHIP_FORM_CREATE_TITLE = 'Nova liderança' as const
export const WIZARD_LEADERSHIP_FORM_EDIT_TITLE = 'Atualizar liderança' as const
export const WIZARD_LEADERSHIP_CONTINUE_LABEL = 'Continuar' as const
export const WIZARD_LEADERSHIP_CONCLUDE_LABEL = 'Concluir' as const
export const WIZARD_LEADERSHIP_ADD_TILE_LABEL = 'Adicionar liderança' as const

View file

@ -1,7 +1,4 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
const politicalTrendDisplayLabels: Record<PoliticalTrendStatusValue, string> = {
favoravel: 'Favorável',
@ -17,11 +14,6 @@ export const WIZARD_TREND_SAVED_MESSAGE = 'Tendência política registrada.' as
export const WIZARD_TREND_UNREGISTERED_TITLE = 'Tendência não registrada' as const
export type WizardTrendSkipAction = {
label: string
href: string
}
export const wizardTrendChoiceStepTitle = (
currentStatus: PoliticalTrendStatusValue | null,
): string =>
@ -57,79 +49,3 @@ export const resolveWizardTrendNoteDestination = (
return 'note'
}
export const shouldShowWizardTrendSkip = (
entryAction: CampaignWizardActionId | undefined,
): boolean => entryAction != null && entryAction !== 'change-trend'
export const resolveWizardTrendSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardTrendSkipAction | undefined =>
shouldShowWizardTrendSkip(entryAction)
? {
label: WIZARD_CHAIN_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'change-trend'),
'change-trend',
municipalitySlug,
returnPath,
),
}
: undefined
export type PoliticalTrendNotePrefillSource =
| { kind: 'none' }
| { kind: 'voteAdjustment'; previousValue: number; newValue: number }
| { kind: 'custom'; text: string }
export const buildPoliticalTrendNotePrefill = (source: PoliticalTrendNotePrefillSource): string => {
switch (source.kind) {
case 'none':
return ''
case 'voteAdjustment':
return `Ajuste de votos: ${source.previousValue} → ${source.newValue}`
case 'custom':
return source.text
default: {
const exhaustive: never = source
return exhaustive
}
}
}
const parseOptionalIntParam = (value: string | string[] | undefined): number | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) return undefined
const parsed = Number.parseInt(trimmed, 10)
return Number.isFinite(parsed) ? parsed : undefined
}
export const resolvePoliticalTrendNotePrefillSource = ({
entryAction,
notePrefill,
voteFrom,
voteTo,
}: {
entryAction?: CampaignWizardActionId
notePrefill?: string | string[] | undefined
voteFrom?: string | string[] | undefined
voteTo?: string | string[] | undefined
}): PoliticalTrendNotePrefillSource => {
const customText = Array.isArray(notePrefill) ? notePrefill[0] : notePrefill
if (customText?.trim()) {
return { kind: 'custom', text: customText.trim() }
}
if (entryAction === 'update-votes') {
const previousValue = parseOptionalIntParam(voteFrom)
const newValue = parseOptionalIntParam(voteTo)
if (previousValue !== undefined && newValue !== undefined) {
return { kind: 'voteAdjustment', previousValue, newValue }
}
}
return { kind: 'none' }
}

View file

@ -1,240 +0,0 @@
/**
* 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,
campaignWizardActionIdForSlug,
isWizardReturnPath,
wizardActionHref,
wizardTrendHref,
type CampaignWizardActionId,
} 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-update', 'update-leadership'],
'register-update': ['change-trend', 'update-votes', 'update-leadership'],
'change-trend': ['register-update', 'update-votes', 'update-leadership'],
'update-leadership': ['register-update', '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,
returnPath?: string,
): string => {
const actionSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action]
switch (action) {
case 'update-votes':
case 'update-leadership':
case 'register-update':
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
case 'change-trend':
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)
default: {
const exhaustive: never = action
return exhaustive
}
}
}
/** Chain end or dismiss target: allowlisted `from`, else Início (B110). */
export const wizardChainEndHref = (returnPath?: string): string =>
returnPath && isWizardReturnPath(returnPath) ? returnPath : CAMPAIGN_HOME
/**
* Href for the next chained wizard after completing/skipping `completedAction`,
* or the return path (default Início) when the queue is empty / entry is not chainable.
*/
export const wizardChainContinueHref = (
entryAction: CampaignWizardActionId,
completedAction: WizardChainActionId,
municipalitySlug: string,
returnPath?: string,
): string => {
if (!isWizardChainActionId(entryAction)) {
return wizardChainEndHref(returnPath)
}
const next = nextWizardChainStep(entryAction, completedAction)
if (!next) {
return wizardChainEndHref(returnPath)
}
return wizardHrefForChainStep(next, municipalitySlug, entryAction, returnPath)
}
/** Session owner for navigation: explicit `entry` query, else the current principal. */
export const resolveWizardChainEntry = (
entryAction: CampaignWizardActionId | undefined,
currentAction: WizardChainActionId,
): WizardChainActionId => (isWizardChainActionId(entryAction) ? entryAction : currentAction)
/**
* register-update, update-votes, update-leadership all use wizardActionHref.
*/
type WizardStepKind =
| 'municipality-search'
| 'votes'
| 'trend-choice'
| 'trend-note'
| 'update-body'
| 'leadership-grid'
| 'leadership-form'
const wizardPrincipalStepHref = (
action: WizardChainActionId,
municipalitySlug: string,
sessionEntry: WizardChainActionId,
returnPath?: string,
): string => {
const actionSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action]
switch (action) {
case 'update-votes':
case 'update-leadership':
case 'register-update':
return wizardActionHref(actionSlug, municipalitySlug, {
entryAction: sessionEntry,
returnPath,
})
case 'change-trend':
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
sessionEntry,
undefined,
returnPath,
)
default: {
const exhaustive: never = action
return exhaustive
}
}
}
/**
* Principal step of the chain link immediately before `current` in a session owned by
* `sessionEntry`. Undefined when `current` is the session principal (standalone entry).
*/
const wizardChainPreviousPrincipalHref = (
current: WizardChainActionId,
sessionEntry: WizardChainActionId,
municipalitySlug: string,
returnPath?: string,
): string | undefined => {
if (current === sessionEntry) {
return undefined
}
const queue = wizardChainAfter(sessionEntry)
const index = queue.indexOf(current)
if (index === -1) {
return undefined
}
if (index === 0) {
return wizardPrincipalStepHref(sessionEntry, municipalitySlug, sessionEntry, returnPath)
}
return wizardPrincipalStepHref(queue[index - 1], municipalitySlug, sessionEntry, returnPath)
}
type WizardPreviousHrefInput = {
actionSlug: string
stepKind: WizardStepKind
municipalitySlug?: string
entryAction?: CampaignWizardActionId
returnPath?: string
}
/**
* Href for the logically previous wizard step — internal sub-steps, principal search,
* or the preceding B98 chain link's principal step (same município).
*/
export const wizardPreviousHref = (input: WizardPreviousHrefInput): string => {
const { actionSlug, stepKind, municipalitySlug, entryAction, returnPath } = input
if (stepKind === 'municipality-search') {
return wizardChainEndHref(returnPath)
}
if (stepKind === 'trend-note') {
return wizardTrendHref(
actionSlug,
municipalitySlug,
undefined,
entryAction,
undefined,
returnPath,
)
}
if (stepKind === 'leadership-form') {
return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })
}
const currentActionId = campaignWizardActionIdForSlug(actionSlug)
const searchHref = wizardActionHref(actionSlug, undefined, { returnPath })
if (!municipalitySlug || !currentActionId || !isWizardChainActionId(currentActionId)) {
return searchHref
}
const sessionEntry = resolveWizardChainEntry(entryAction, currentActionId)
if (
(stepKind === 'votes' || stepKind === 'leadership-grid') &&
currentActionId === sessionEntry
) {
return searchHref
}
const chainPrev = wizardChainPreviousPrincipalHref(
currentActionId,
sessionEntry,
municipalitySlug,
returnPath,
)
if (chainPrev) {
return chainPrev
}
return searchHref
}

View file

@ -1,7 +1,4 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_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 = {
@ -13,30 +10,3 @@ export type WizardLeadershipTileViewModel = {
exclusive: boolean
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,
returnPath?: string,
): WizardLeadershipSkipAction | undefined =>
showLeadershipWizardSkip(entryAction)
? {
label: WIZARD_CHAIN_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'update-leadership'),
'update-leadership',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -1,33 +1,3 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain'
export const WIZARD_UPDATE_BODY_STEP_TITLE = 'Registrar atualização' as const
export const WIZARD_UPDATE_SAVE_LABEL = 'Salvar' as const
export type WizardUpdateSkipAction = {
label: string
href: string
}
export const shouldShowWizardUpdateSkip = (
entryAction: CampaignWizardActionId | undefined,
): boolean => entryAction != null && entryAction !== 'register-update'
export const resolveWizardUpdateSkip = (
entryAction: CampaignWizardActionId | undefined,
municipalitySlug: string,
returnPath?: string,
): WizardUpdateSkipAction | undefined =>
shouldShowWizardUpdateSkip(entryAction)
? {
label: WIZARD_CHAIN_SKIP_LABEL,
href: wizardChainContinueHref(
resolveWizardChainEntry(entryAction, 'register-update'),
'register-update',
municipalitySlug,
returnPath,
),
}
: undefined

View file

@ -239,15 +239,37 @@ 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 page.waitForURL(/^https?:\/\/[^/]+\/campanha\/?$/)
await expect(page.getByText('Votos estimados atualizados.')).toBeVisible({ timeout: 15000 })
expect(page.url()).not.toContain('cenario=')
})
test('saving votes returns to the allowlisted origin (B110 backdrop)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`/campanha/acoes/atualizar-votos?municipio=${municipality.slug}&from=${encodeURIComponent(`/campanha/municipios/${municipality.slug}`)}`,
)
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
timeout: 15000,
})
const step = wizardVoteStep(page)
await step.getByRole('textbox', { name: 'Média' }).fill('210')
await page.getByRole('button', { name: 'Salvar estimativas →' }).click()
await page.waitForURL(new RegExp(`/campanha/municipios/${municipality.slug}\\/?$`))
})
test('incoherent estimates show inline warning without changing URL', async ({
campaign,
page,
@ -298,7 +320,7 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
})
test.describe('Wizard — atualizar liderança (B70)', () => {
test('create leadership, save, and continue into the chain', async ({ campaign, page }) => {
test('create leadership, save, and return to origin (B168)', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -323,20 +345,19 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({
timeout: 15000,
})
await expect(page.getByRole('button', { name: 'Continuar' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Concluir' })).toBeVisible()
await page.getByRole('button', { name: 'Continuar' }).click()
await page.waitForURL(
new RegExp(
`/campanha/acoes/registrar-atualizacao\\?municipio=${municipality.slug}&entry=update-leadership`,
),
)
await expect(page.getByRole('heading', { name: 'Registrar atualização' })).toBeVisible()
await page.getByRole('button', { name: 'Concluir' }).click()
await page.waitForURL(/^https?:\/\/[^/]+\/campanha\/?$/)
await expect(page.getByRole('link', { name: /Voltar/ })).toHaveCount(0)
})
})
test.describe('Wizard — registrar atualização (C87)', () => {
test('standalone flow: unified form, save continues the chain', async ({ campaign, page }) => {
test('standalone flow: unified form, save returns to origin (B168)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -358,11 +379,7 @@ test.describe('Wizard — registrar atualização (C87)', () => {
await page.getByLabel('Polaridade').selectOption('ruim')
await page.getByRole('button', { name: 'Salvar' }).click()
await page.waitForURL(
new RegExp(
`/campanha/acoes/mudar-tendencia\\?municipio=${municipality.slug}&entry=register-update`,
),
)
await page.waitForURL(/^https?:\/\/[^/]+\/campanha\/?$/)
await expect(page.getByText('Atualização registrada com sucesso.')).toBeVisible()
})
@ -389,7 +406,7 @@ test.describe('Wizard — registrar atualização (C87)', () => {
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toHaveCount(0)
})
test('embedded flow shows skip link to the next chain step', async ({ campaign, page }) => {
test('stale entry param is ignored — never a skip link (B168)', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
@ -401,11 +418,40 @@ test.describe('Wizard — registrar atualização (C87)', () => {
`/campanha/acoes/registrar-atualizacao?municipio=${municipality.slug}&entry=update-votes`,
)
const skip = page.getByRole('link', { name: 'Pular' })
await expect(skip).toBeVisible({ timeout: 15000 })
await expect(skip).toHaveAttribute(
'href',
`/campanha/acoes/atualizar-lideranca?municipio=${municipality.slug}&entry=update-votes`,
)
await expect(page.getByRole('heading', { name: 'Registrar atualização' })).toBeVisible({
timeout: 15000,
})
await expect(page.getByRole('link', { name: 'Pular' })).toHaveCount(0)
})
})
test.describe('Wizard — mudar tendência (B97 / B168)', () => {
test('changing trend and saving returns to origin instead of advancing', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`/campanha/acoes/mudar-tendencia?municipio=${municipality.slug}`)
await expect(page.getByRole('heading', { name: /Tendência/ })).toBeVisible({
timeout: 15000,
})
await page.locator('main a[href*="trendStatus"]').first().click()
await expect(page.getByRole('heading', { name: /^Mudar tendência para / })).toBeVisible({
timeout: 15000,
})
await expect(page.getByRole('link', { name: 'Pular' })).toHaveCount(0)
await page.getByRole('button', { name: 'Salvar' }).click()
await page.waitForURL(/^https?:\/\/[^/]+\/campanha\/?$/)
await expect(page.getByText('Tendência política registrada.')).toBeVisible()
})
})

View file

@ -8,13 +8,15 @@ import {
hasWizardScenarioParam,
isCampaignWizardActionId,
isCampaignWizardActionSlug,
parseWizardEntryActionParam,
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
resolveWizardTrendStatusParam,
wizardActionHref,
wizardPreviousHref,
wizardReturnHref,
wizardTrendHref,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
describe('campaignActionRoutes', () => {
it.each(
@ -40,9 +42,6 @@ describe('campaignActionRoutes', () => {
expect(wizardActionHref('atualizar-votos', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
)
expect(wizardActionHref('atualizar-lideranca', 'cairu', { entryAction: 'update-votes' })).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=update-votes`,
)
expect(wizardActionHref('atualizar-lideranca', 'cairu', { leadershipId: 42 })).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42`,
)
@ -74,22 +73,16 @@ describe('campaignActionRoutes', () => {
expect(campaignWizardActionIdForSlug('inventado')).toBeUndefined()
})
it('parses entry action search param', () => {
expect(parseWizardEntryActionParam('update-leadership')).toBe('update-leadership')
expect(parseWizardEntryActionParam('update-votes')).toBe('update-votes')
expect(parseWizardEntryActionParam('invalid')).toBeUndefined()
expect(parseWizardEntryActionParam(undefined)).toBeUndefined()
})
it('builds trend wizard hrefs with optional trendStatus and entry', () => {
it('builds trend wizard hrefs with optional trendStatus and return path', () => {
expect(wizardTrendHref('mudar-tendencia', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu`,
)
expect(wizardTrendHref('mudar-tendencia', 'cairu', 'favoravel')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&trendStatus=favoravel`,
)
expect(wizardTrendHref('mudar-tendencia', 'cairu', 'neutra', 'update-votes')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&trendStatus=neutra&entry=update-votes`,
const origin = '/campanha/municipios/cairu'
expect(wizardTrendHref('mudar-tendencia', 'cairu', 'neutra', origin)).toBe(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&trendStatus=neutra&from=${encodeURIComponent(origin)}`,
)
})
@ -108,3 +101,93 @@ describe('campaignActionRoutes', () => {
})
})
})
describe('wizardReturnHref (B110)', () => {
it('falls back to Início without a return path', () => {
expect(wizardReturnHref(undefined)).toBe(CAMPAIGN_HOME)
})
it('honors an allowlisted return path', () => {
expect(wizardReturnHref('/campanha/municipios/cairu')).toBe('/campanha/municipios/cairu')
})
it.each(['/campanha/acoes/atualizar-votos', '/campanha/login', 'https://evil.example'])(
'rejects %s and falls back to Início',
(path) => {
expect(wizardReturnHref(path)).toBe(CAMPAIGN_HOME)
},
)
})
describe('wizardPreviousHref', () => {
it('returns the return target for municipality search', () => {
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'municipality-search',
}),
).toBe(CAMPAIGN_HOME)
})
it('returns municipality search for principal post-municipio steps', () => {
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'votes',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`)
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-choice',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia`)
expect(
wizardPreviousHref({
actionSlug: 'registrar-atualizacao',
stepKind: 'update-body',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/registrar-atualizacao`)
expect(
wizardPreviousHref({
actionSlug: 'atualizar-lideranca',
stepKind: 'leadership-grid',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca`)
})
it('returns the trend choice step from the trend note step', () => {
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-note',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu`)
})
it('returns the leadership grid from the leadership form step', () => {
expect(
wizardPreviousHref({
actionSlug: 'atualizar-lideranca',
stepKind: 'leadership-form',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu`)
})
it('honors return path on municipality search back target', () => {
const origin = '/campanha/municipios/cairu'
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'municipality-search',
returnPath: origin,
}),
).toBe(origin)
})
})

View file

@ -135,24 +135,20 @@ describe('CampaignMobileTopBar', () => {
expect(screen.getByRole('link', { name: 'Sair da ação' })).toBeTruthy()
})
it('renders skip link instead of dismiss when chained flow defines skip', () => {
it('renders dismiss on every wizard step and never a skip link (B168)', () => {
renderTopBar({
wizardChrome: {
flowTitle: 'Registrar atualização',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos?municipio=cairu',
dismissHref: '/campanha',
skip: {
label: 'Pular',
href: '/campanha/acoes/mudar-tendencia?municipio=cairu',
},
},
})
expect(screen.getByRole('link', { name: 'Pular' }).getAttribute('href')).toBe(
'/campanha/acoes/mudar-tendencia?municipio=cairu',
expect(screen.getByRole('link', { name: 'Sair da ação' }).getAttribute('href')).toBe(
'/campanha',
)
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
expect(screen.queryByRole('link', { name: 'Pular' })).toBeNull()
})
it('keeps wizard mode when chrome updates between steps', () => {

View file

@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import {
resolveWizardVotesSkip,
WIZARD_CHAIN_SKIP_LABEL,
WIZARD_DISMISS_ARIA_LABEL,
wizardFlowChromeAriaLabel,
wizardFlowTitleForSlug,
@ -22,14 +19,4 @@ describe('campaignWizardCopy', () => {
expect(wizardMunicipalityChromeAriaLabel('Cairu')).toBe('Município em atualização: Cairu')
expect(WIZARD_DISMISS_ARIA_LABEL).toBe('Sair da ação')
})
it('uses the shared chain skip label for embedded vote adjustments', () => {
expect(WIZARD_CHAIN_SKIP_LABEL).toBe('Pular')
expect(resolveWizardVotesSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardVotesSkip('update-votes', 'cairu')).toBeUndefined()
expect(resolveWizardVotesSkip('change-trend', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=change-trend`,
})
})
})

View file

@ -1,18 +1,12 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import {
politicalTrendWizardMeta,
politicalTrendWizardMetaByStatus,
} from '@/lib/politicalTrendWizardMeta'
import {
buildPoliticalTrendNotePrefill,
resolvePoliticalTrendNotePrefillSource,
resolveWizardTrendNoteDestination,
resolveWizardTrendSkip,
selectablePoliticalTrendStatuses,
shouldShowWizardTrendSkip,
WIZARD_TREND_UNREGISTERED_TITLE,
wizardTrendChoiceStepTitle,
} from '@/lib/politicalTrendWizardUi'
@ -35,58 +29,6 @@ describe('politicalTrendWizardUi', () => {
expect(resolveWizardTrendNoteDestination('favoravel', 'favoravel')).toBe('home')
expect(resolveWizardTrendNoteDestination('favoravel', null)).toBe('note')
})
it('hides skip for standalone change-trend entry', () => {
expect(shouldShowWizardTrendSkip(undefined)).toBe(false)
expect(shouldShowWizardTrendSkip('change-trend')).toBe(false)
expect(resolveWizardTrendSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardTrendSkip('change-trend', 'cairu')).toBeUndefined()
})
it('shows skip to the next chain step when embedded from another wizard', () => {
expect(shouldShowWizardTrendSkip('update-votes')).toBe(true)
expect(shouldShowWizardTrendSkip('register-update')).toBe(true)
expect(resolveWizardTrendSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/registrar-atualizacao?municipio=cairu&entry=update-votes`,
})
})
it('builds note prefills from embedded wizard sources', () => {
expect(buildPoliticalTrendNotePrefill({ kind: 'none' })).toBe('')
expect(
buildPoliticalTrendNotePrefill({
kind: 'voteAdjustment',
previousValue: 120,
newValue: 180,
}),
).toBe('Ajuste de votos: 120 → 180')
expect(buildPoliticalTrendNotePrefill({ kind: 'custom', text: 'Contexto do fluxo' })).toBe(
'Contexto do fluxo',
)
})
it('resolves prefill sources from entry action and query params', () => {
expect(
resolvePoliticalTrendNotePrefillSource({
entryAction: 'update-votes',
voteFrom: '100',
voteTo: '150',
}),
).toEqual({ kind: 'voteAdjustment', previousValue: 100, newValue: 150 })
expect(
resolvePoliticalTrendNotePrefillSource({
entryAction: 'register-update',
}),
).toEqual({ kind: 'none' })
expect(
resolvePoliticalTrendNotePrefillSource({
notePrefill: 'Texto direto',
}),
).toEqual({ kind: 'custom', text: 'Texto direto' })
})
})
describe('politicalTrendWizardMeta', () => {

View file

@ -1,168 +0,0 @@
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,
wizardPreviousHref,
} 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-update',
'update-leadership',
])
expect(wizardChainAfter('update-votes', 'update-votes')).toEqual([
'change-trend',
'register-update',
'update-leadership',
])
expect(wizardChainAfter('register-update', 'register-update')).toEqual([
'change-trend',
'update-votes',
'update-leadership',
])
expect(wizardChainAfter('change-trend', 'change-trend')).toEqual([
'register-update',
'update-votes',
'update-leadership',
])
expect(wizardChainAfter('update-leadership', 'update-leadership')).toEqual([
'register-update',
'update-votes',
'change-trend',
])
})
it('slices the remaining queue after a chained step', () => {
expect(wizardChainAfter('update-votes', 'change-trend')).toEqual([
'register-update',
'update-leadership',
])
expect(wizardChainAfter('update-votes', 'register-update')).toEqual(['update-leadership'])
expect(wizardChainAfter('update-votes', 'update-leadership')).toEqual([])
expect(nextWizardChainStep('change-trend', 'register-update')).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-atualizacao?municipio=cairu&entry=update-votes`,
)
expect(wizardChainContinueHref('register-update', 'change-trend', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-update`,
)
expect(wizardChainContinueHref('change-trend', 'update-leadership', 'cairu')).toBe(
CAMPAIGN_HOME,
)
expect(wizardChainContinueHref('register-demand', 'update-votes', 'cairu')).toBe(CAMPAIGN_HOME)
})
it('honors allowlisted return path when the chain ends', () => {
const origin = '/campanha/municipios/cairu'
expect(wizardChainContinueHref('change-trend', 'update-leadership', 'cairu', origin)).toBe(
origin,
)
expect(wizardChainContinueHref('update-votes', 'change-trend', 'cairu', origin)).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-atualizacao?municipio=cairu&entry=update-votes&from=${encodeURIComponent(origin)}`,
)
})
it('resolves session entry from query or current principal', () => {
expect(resolveWizardChainEntry(undefined, 'update-votes')).toBe('update-votes')
expect(resolveWizardChainEntry('register-update', 'update-votes')).toBe('register-update')
expect(resolveWizardChainEntry('register-demand', 'change-trend')).toBe('change-trend')
})
describe('wizardPreviousHref', () => {
it('returns chain-end for municipality search', () => {
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'municipality-search',
}),
).toBe(CAMPAIGN_HOME)
})
it('returns municipality search for principal post-municipio steps', () => {
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'votes',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`)
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-choice',
municipalitySlug: 'cairu',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia`)
})
it('returns previous chain principal when chained from another entry', () => {
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-choice',
municipalitySlug: 'cairu',
entryAction: 'update-votes',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=update-votes`)
expect(
wizardPreviousHref({
actionSlug: 'registrar-atualizacao',
stepKind: 'update-body',
municipalitySlug: 'cairu',
entryAction: 'update-votes',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`)
expect(
wizardPreviousHref({
actionSlug: 'atualizar-lideranca',
stepKind: 'leadership-grid',
municipalitySlug: 'cairu',
entryAction: 'register-update',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=register-update`)
})
it('returns internal previous step within a subflow', () => {
expect(
wizardPreviousHref({
actionSlug: 'mudar-tendencia',
stepKind: 'trend-note',
municipalitySlug: 'cairu',
entryAction: 'update-votes',
}),
).toBe(`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`)
})
it('honors return path on municipality search back target', () => {
const origin = '/campanha/municipios/cairu'
expect(
wizardPreviousHref({
actionSlug: 'atualizar-votos',
stepKind: 'municipality-search',
returnPath: origin,
}),
).toBe(origin)
})
})
})

View file

@ -3,7 +3,6 @@ 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 pushMock = vi.fn()
@ -96,7 +95,7 @@ describe('WizardExpectedVotesStep', () => {
expect(screen.getByLabelText('Pessimista').getAttribute('aria-invalid')).toBeNull()
})
it('posts batch expected votes and continues the wizard chain', async () => {
it('posts batch expected votes and returns to origin', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: {
@ -120,18 +119,32 @@ describe('WizardExpectedVotesStep', () => {
})
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith(
`${CAMPAIGN_ACTIONS_HOME}/mudar-tendencia?municipio=cairu&entry=update-votes`,
)
expect(pushMock).toHaveBeenCalledWith('/campanha')
})
})
it('shows skip to the next chain step when votes is chained', () => {
renderVotesStep({ ...defaultProps, entryAction: 'register-update' })
it('returns to the allowlisted origin when a return path is set (B110)', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: {
status: 'success',
savedExpectedVotes: { pessimistic: 150, central: 250, optimistic: 350 },
},
})
const skip = screen.getByRole('link', { name: /^Pular$/i })
expect(skip.getAttribute('href')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=register-update`,
)
renderVotesStep({ ...defaultProps, returnPath: '/campanha/municipios/cairu' })
fireEvent.change(screen.getByLabelText('Média'), { target: { value: '250' } })
fireEvent.click(screen.getByRole('button', { name: 'Salvar estimativas →' }))
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith('/campanha/municipios/cairu')
})
})
it('never renders a chain skip link (B168)', () => {
renderVotesStep({ ...defaultProps })
expect(screen.queryByRole('link', { name: /^Pular$/i })).toBeNull()
})
})

View file

@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import {
resolveWizardLeadershipSkip,
showLeadershipWizardSkip,
} from '@/lib/wizardLeadershipContract'
describe('showLeadershipWizardSkip', () => {
it('hides skip for standalone entry (implicit update-leadership)', () => {
expect(showLeadershipWizardSkip(undefined)).toBe(false)
})
it('hides skip when entry is update-leadership', () => {
expect(showLeadershipWizardSkip('update-leadership')).toBe(false)
})
it('shows skip when embedded from another wizard action', () => {
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 origin when from is set, else Início at chain end', () => {
const origin = '/campanha/liderancas/7'
expect(resolveWizardLeadershipSkip('update-votes', 'cairu', origin)).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: origin,
})
expect(resolveWizardLeadershipSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: CAMPAIGN_HOME,
})
})
})

View file

@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { WIZARD_CHAIN_SKIP_LABEL } from '@/lib/campaignWizardCopy'
import { resolveWizardUpdateSkip, shouldShowWizardUpdateSkip } from '@/lib/wizardUpdateUi'
describe('wizardUpdateUi', () => {
it('hides skip for standalone register-update entry', () => {
expect(shouldShowWizardUpdateSkip(undefined)).toBe(false)
expect(shouldShowWizardUpdateSkip('register-update')).toBe(false)
expect(resolveWizardUpdateSkip(undefined, 'cairu')).toBeUndefined()
expect(resolveWizardUpdateSkip('register-update', 'cairu')).toBeUndefined()
})
it('shows skip to the next chain step when embedded from another wizard', () => {
expect(shouldShowWizardUpdateSkip('update-votes')).toBe(true)
expect(shouldShowWizardUpdateSkip('change-trend')).toBe(true)
expect(resolveWizardUpdateSkip('update-votes', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=update-votes`,
})
expect(resolveWizardUpdateSkip('change-trend', 'cairu')).toEqual({
label: WIZARD_CHAIN_SKIP_LABEL,
href: `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&entry=change-trend`,
})
})
})