From 7faf3ed27f0fbfc09081b5d8555daa1bafb2da27 Mon Sep 17 00:00:00 2001 From: fsolla Date: Sat, 8 Aug 2026 14:10:47 -0300 Subject: [PATCH] =?UTF-8?q?feat(campanha):=20B168=20=E2=80=94=20desfazer?= =?UTF-8?q?=20encadeamento=20autom=C3=A1tico=20das=20a=C3=A7=C3=B5es=20r?= =?UTF-8?q?=C3=A1pidas=20do=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/CHANGELOG-AGENTS.md | 2 + ...desfazer-encadeamento-acoes-wizard-impl.md | 81 ++++++ .../campanha/(app)/acoes/[slug]/page.tsx | 90 +------ .../leadership/WizardLeadershipStep.tsx | 41 +-- .../municipality/WizardSkipTrailing.tsx | 13 - .../municipality/WizardTrendChoiceStep.tsx | 25 +- .../municipality/WizardTrendNoteStep.tsx | 30 +-- .../municipality/WizardTrendSkipTrailing.tsx | 13 - .../municipality/WizardUpdateBodyStep.tsx | 28 +- .../campaign/shared/CampaignWizardShell.tsx | 10 +- .../shared/WizardExpectedVotesStep.tsx | 27 +- .../shared/WizardMunicipalitySelectedStub.tsx | 5 +- .../campaign/shell/CampaignMobileTopBar.tsx | 42 +-- .../shell/CampaignWizardChromeContext.tsx | 9 - src/lib/campaignActionRoutes.ts | 84 +++--- src/lib/campaignWizardCopy.ts | 31 +-- src/lib/politicalTrendWizardUi.ts | 84 ------ src/lib/wizardActionChain.ts | 240 ------------------ src/lib/wizardLeadershipContract.ts | 30 --- src/lib/wizardUpdateUi.ts | 30 --- tests/e2e/campaignHomeActions.e2e.spec.ts | 100 ++++++-- tests/unit/campaignActionRoutes.unit.spec.ts | 111 +++++++- tests/unit/campaignMobileTopBar.unit.spec.tsx | 12 +- tests/unit/campaignWizardCopy.unit.spec.ts | 13 - .../unit/politicalTrendWizardUi.unit.spec.ts | 58 ----- tests/unit/wizardActionChain.unit.spec.ts | 168 ------------ .../wizardExpectedVotesStep.unit.spec.tsx | 35 ++- tests/unit/wizardLeadershipStep.unit.spec.ts | 41 --- tests/unit/wizardUpdateUi.unit.spec.ts | 27 -- 29 files changed, 376 insertions(+), 1104 deletions(-) create mode 100644 docs/plans/desfazer-encadeamento-acoes-wizard-impl.md delete mode 100644 src/components/campaign/municipality/WizardSkipTrailing.tsx delete mode 100644 src/components/campaign/municipality/WizardTrendSkipTrailing.tsx delete mode 100644 src/lib/wizardActionChain.ts delete mode 100644 tests/unit/wizardActionChain.unit.spec.ts delete mode 100644 tests/unit/wizardLeadershipStep.unit.spec.ts delete mode 100644 tests/unit/wizardUpdateUi.unit.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index fdf86331..6e330458 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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`/`teqo_wt_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*\_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). diff --git a/docs/plans/desfazer-encadeamento-acoes-wizard-impl.md b/docs/plans/desfazer-encadeamento-acoes-wizard-impl.md new file mode 100644 index 00000000..4e3fc9d4 --- /dev/null +++ b/docs/plans/desfazer-encadeamento-acoes-wizard-impl.md @@ -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
(matriz + continue + entry)"] --> D["DELETAR"] + B["skip resolvers + WIZARD_CHAIN_SKIP_LABEL
(4 libs)"] --> D + C["skip UI: Chrome state, MobileTopBar,
Shell trailingAction, Wizard*SkipTrailing"] --> D + E["Fusão em campaignActionRoutes.ts:
wizardReturnHref (ex chainEnd) +
wizardPreviousHref (só passos internos)"] --> F["5 componentes + page.tsx:
sucesso → router.push(wizardReturnHref)
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** diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 9689c117..822898d4 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -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, -): Record | undefined => { - const keys = [ - WIZARD_NOTE_PREFILL_QUERY_KEY, - WIZARD_VOTE_FROM_QUERY_KEY, - WIZARD_VOTE_TO_QUERY_KEY, - ] as const - const result: Record = {} - - 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> @@ -104,7 +70,7 @@ export default async function CampaignActionWizardPage({ return ( @@ -131,10 +97,6 @@ export default async function CampaignActionWizardPage({ redirect(wizardActionHref(slug, municipalitySlug)) } - const entryAction = parseWizardEntryActionParam( - resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY], - ) - return ( ) } 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 ( diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index 9fa5d61d..502e0b89 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -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(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 ? ( - - ) : 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' ? (
) : null} diff --git a/src/components/campaign/municipality/WizardSkipTrailing.tsx b/src/components/campaign/municipality/WizardSkipTrailing.tsx deleted file mode 100644 index 1e25c87e..00000000 --- a/src/components/campaign/municipality/WizardSkipTrailing.tsx +++ /dev/null @@ -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) => ( - -) diff --git a/src/components/campaign/municipality/WizardTrendChoiceStep.tsx b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx index 6f4132f5..706463d2 100644 --- a/src/components/campaign/municipality/WizardTrendChoiceStep.tsx +++ b/src/components/campaign/municipality/WizardTrendChoiceStep.tsx @@ -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 returnPath?: string } @@ -47,12 +41,9 @@ export const WizardTrendChoiceStep = ({ municipalityName, municipalitySlug, currentStatus, - entryAction, - prefillExtraParams, returnPath, }: WizardTrendChoiceStepProps) => { const [infoEntry, setInfoEntry] = useState(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 ? : undefined} >
    {options.map((status) => { @@ -80,14 +68,7 @@ export const WizardTrendChoiceStep = ({ return (
  • { 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 ? : undefined} contentFocus="none" >
    ( - -) diff --git a/src/components/campaign/municipality/WizardUpdateBodyStep.tsx b/src/components/campaign/municipality/WizardUpdateBodyStep.tsx index bbf85545..38df4cae 100644 --- a/src/components/campaign/municipality/WizardUpdateBodyStep.tsx +++ b/src/components/campaign/municipality/WizardUpdateBodyStep.tsx @@ -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 ? : undefined} contentFocus="none" > - {trailingAction ?
    {trailingAction}
    : null} -
    { const router = useRouter() @@ -66,7 +56,6 @@ export const WizardExpectedVotesStep = ({ const [focusedScenario, setFocusedScenario] = useState('central') const [violation, setViolation] = useState(null) const [saveError, setSaveError] = useState(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 ? ( - - ) : undefined - return (

    {wizardNextStepPlaceholder(actionSlug)}

    diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx index a8bf9d56..65e96246 100644 --- a/src/components/campaign/shell/CampaignMobileTopBar.tsx +++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx @@ -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?:
    - {chrome.skip ? ( - - ) : ( - - )} +
    ) diff --git a/src/components/campaign/shell/CampaignWizardChromeContext.tsx b/src/components/campaign/shell/CampaignWizardChromeContext.tsx index 79a0cf37..8a4ca941 100644 --- a/src/components/campaign/shell/CampaignWizardChromeContext.tsx +++ b/src/components/campaign/shell/CampaignWizardChromeContext.tsx @@ -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 => { diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts index 976847d5..6617f986 100644 --- a/src/lib/campaignActionRoutes.ts +++ b/src/lib/campaignActionRoutes.ts @@ -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, 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 }) +} diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts index d47e99fa..3303e136 100644 --- a/src/lib/campaignWizardCopy.ts +++ b/src/lib/campaignWizardCopy.ts @@ -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 diff --git a/src/lib/politicalTrendWizardUi.ts b/src/lib/politicalTrendWizardUi.ts index 18789169..a10eff3e 100644 --- a/src/lib/politicalTrendWizardUi.ts +++ b/src/lib/politicalTrendWizardUi.ts @@ -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 = { 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' } -} diff --git a/src/lib/wizardActionChain.ts b/src/lib/wizardActionChain.ts deleted file mode 100644 index d22c4768..00000000 --- a/src/lib/wizardActionChain.ts +++ /dev/null @@ -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 - -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 - -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 -} diff --git a/src/lib/wizardLeadershipContract.ts b/src/lib/wizardLeadershipContract.ts index 283734c8..3c8aa699 100644 --- a/src/lib/wizardLeadershipContract.ts +++ b/src/lib/wizardLeadershipContract.ts @@ -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 diff --git a/src/lib/wizardUpdateUi.ts b/src/lib/wizardUpdateUi.ts index f623d762..3fac7e9d 100644 --- a/src/lib/wizardUpdateUi.ts +++ b/src/lib/wizardUpdateUi.ts @@ -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 diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index 4fcb8015..d35966d0 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -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() }) }) diff --git a/tests/unit/campaignActionRoutes.unit.spec.ts b/tests/unit/campaignActionRoutes.unit.spec.ts index 5455b793..9a9e3f57 100644 --- a/tests/unit/campaignActionRoutes.unit.spec.ts +++ b/tests/unit/campaignActionRoutes.unit.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/campaignMobileTopBar.unit.spec.tsx b/tests/unit/campaignMobileTopBar.unit.spec.tsx index 256e4d08..53c59ba8 100644 --- a/tests/unit/campaignMobileTopBar.unit.spec.tsx +++ b/tests/unit/campaignMobileTopBar.unit.spec.tsx @@ -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', () => { diff --git a/tests/unit/campaignWizardCopy.unit.spec.ts b/tests/unit/campaignWizardCopy.unit.spec.ts index d9ebeacd..28d13016 100644 --- a/tests/unit/campaignWizardCopy.unit.spec.ts +++ b/tests/unit/campaignWizardCopy.unit.spec.ts @@ -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`, - }) - }) }) diff --git a/tests/unit/politicalTrendWizardUi.unit.spec.ts b/tests/unit/politicalTrendWizardUi.unit.spec.ts index d08d99d1..b281cb4e 100644 --- a/tests/unit/politicalTrendWizardUi.unit.spec.ts +++ b/tests/unit/politicalTrendWizardUi.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/wizardActionChain.unit.spec.ts b/tests/unit/wizardActionChain.unit.spec.ts deleted file mode 100644 index 4e97fd4b..00000000 --- a/tests/unit/wizardActionChain.unit.spec.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx index d6e8970c..ccddb1ac 100644 --- a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx +++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx @@ -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() }) }) diff --git a/tests/unit/wizardLeadershipStep.unit.spec.ts b/tests/unit/wizardLeadershipStep.unit.spec.ts deleted file mode 100644 index 5f106279..00000000 --- a/tests/unit/wizardLeadershipStep.unit.spec.ts +++ /dev/null @@ -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, - }) - }) -}) diff --git a/tests/unit/wizardUpdateUi.unit.spec.ts b/tests/unit/wizardUpdateUi.unit.spec.ts deleted file mode 100644 index 99bdc043..00000000 --- a/tests/unit/wizardUpdateUi.unit.spec.ts +++ /dev/null @@ -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`, - }) - }) -})