From d5697bcfcbef5a531386c92c79cbad189b4a14de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 20:39:24 +0000 Subject: [PATCH] =?UTF-8?q?B110=20=E2=80=94=20wizard=20return=20path=20via?= =?UTF-8?q?=20allowlisted=20from=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagate return path from quick actions through the B98 wizard chain so dismiss, skip, and chain end navigate back to the originating page instead of always redirecting to Início. Co-authored-by: Francisco Solla --- .../campanha/(app)/acoes/[slug]/page.tsx | 29 ++++++++-- .../dashboard/CampaignHomeActions.tsx | 6 +- .../leadership/WizardLeadershipStep.tsx | 12 ++-- .../municipality/WizardSignalBodyStep.tsx | 19 +++++-- .../municipality/WizardSignalTypeStep.tsx | 16 +++++- .../municipality/WizardTrendChoiceStep.tsx | 9 ++- .../municipality/WizardTrendNoteStep.tsx | 20 +++++-- .../shared/WizardExpectedVotesStep.tsx | 15 +++-- .../shared/WizardMunicipalitySearchStep.tsx | 4 +- .../shared/WizardMunicipalitySelectedStub.tsx | 8 ++- src/lib/activityQuickActions.ts | 12 +++- src/lib/campaignActionRoutes.ts | 56 +++++++++++++++++-- src/lib/campaignHomeActions.ts | 24 ++++++-- src/lib/campaignMunicipalityQuickActions.ts | 10 ++-- src/lib/campaignQuickActionDemands.ts | 5 +- src/lib/campaignQuickActionDobradinhas.ts | 2 +- src/lib/campaignQuickActionLeadership.ts | 23 +++++--- src/lib/campaignQuickActionRegistry.ts | 6 +- src/lib/campaignWizardCopy.ts | 2 + src/lib/politicalTrendWizardUi.ts | 2 + src/lib/wizardActionChain.ts | 21 ++++--- src/lib/wizardLeadershipContract.ts | 2 + src/lib/wizardSignalUi.ts | 2 + tests/unit/campaignQuickAction.unit.spec.ts | 16 ++++-- ...campaignQuickActionLeadership.unit.spec.ts | 10 ++-- tests/unit/wizardActionChain.unit.spec.ts | 10 ++++ tests/unit/wizardLeadershipStep.unit.spec.ts | 8 ++- tests/unit/wizardReturnPath.unit.spec.ts | 51 +++++++++++++++++ 28 files changed, 315 insertions(+), 85 deletions(-) create mode 100644 tests/unit/wizardReturnPath.unit.spec.ts diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 3b6b2465..d37dc41e 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -15,12 +15,14 @@ import { parseWizardEntryActionParam, parseWizardLeadershipIdParam, parseWizardMunicipioParam, + parseWizardReturnPath, resolveWizardSignalTypeParam, 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_SIGNAL_BODY_QUERY_KEY, WIZARD_SIGNAL_TYPE_QUERY_KEY, @@ -31,14 +33,13 @@ import { wizardSignalHref, wizardTrendHref, } from '@/lib/campaignActionRoutes' -import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { buildPoliticalTrendNotePrefill, resolvePoliticalTrendNotePrefillSource, resolveWizardTrendNoteDestination, } from '@/lib/politicalTrendWizardUi' import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' -import { isWizardChainActionId, wizardChainContinueHref } from '@/lib/wizardActionChain' +import { isWizardChainActionId, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain' import config from '@/payload.config' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadWizardLeadershipTiles } from '@/utilities/leadership/leadershipData' @@ -83,6 +84,7 @@ export default async function CampaignActionWizardPage({ }: CampaignActionWizardPageProps) { const { slug } = await params const resolvedSearchParams = await searchParams + const returnPath = parseWizardReturnPath(resolvedSearchParams[WIZARD_RETURN_PATH_QUERY_KEY]) if (!isCampaignWizardActionSlug(slug)) { notFound() @@ -104,7 +106,8 @@ export default async function CampaignActionWizardPage({ return ( ) @@ -142,6 +145,7 @@ export default async function CampaignActionWizardPage({ municipalitySlug={municipality.slug} initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)} entryAction={entryAction} + returnPath={returnPath} /> ) } @@ -164,6 +168,7 @@ export default async function CampaignActionWizardPage({ entryAction={entryAction} initialTiles={tiles} initialLeadershipId={initialLeadershipId} + returnPath={returnPath} /> ) } @@ -200,9 +205,11 @@ export default async function CampaignActionWizardPage({ if (noteDestination === 'home') { // Chained session: advance the queue (B98). Standalone stale deep-link: Início (B97). if (isWizardChainActionId(entryAction)) { - redirect(wizardChainContinueHref(entryAction, 'change-trend', municipalitySlug)) + redirect( + wizardChainContinueHref(entryAction, 'change-trend', municipalitySlug, returnPath), + ) } - redirect(CAMPAIGN_HOME) + redirect(wizardChainEndHref(returnPath)) } if (noteDestination === 'choice') { @@ -220,6 +227,7 @@ export default async function CampaignActionWizardPage({ trendStatus={trendStatus} initialNote={initialNote} entryAction={entryAction} + returnPath={returnPath} /> ) } @@ -232,6 +240,7 @@ export default async function CampaignActionWizardPage({ currentStatus={currentStatus} entryAction={entryAction} prefillExtraParams={prefillExtraParams} + returnPath={returnPath} /> ) } @@ -257,6 +266,7 @@ export default async function CampaignActionWizardPage({ municipalitySlug={municipality.slug} signalType={signalType} entryAction={entryAction} + returnPath={returnPath} /> ) } @@ -267,9 +277,16 @@ export default async function CampaignActionWizardPage({ municipalityName={municipality.name} municipalitySlug={municipality.slug} entryAction={entryAction} + returnPath={returnPath} /> ) } - return + return ( + + ) } diff --git a/src/components/campaign/dashboard/CampaignHomeActions.tsx b/src/components/campaign/dashboard/CampaignHomeActions.tsx index db70930d..bbd4bbf3 100644 --- a/src/components/campaign/dashboard/CampaignHomeActions.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActions.tsx @@ -2,6 +2,7 @@ import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip' import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions' +import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import type { CampaignRole } from '@/lib/campaignRoles' export const CampaignHomeActions = ({ @@ -11,7 +12,10 @@ export const CampaignHomeActions = ({ role: CampaignRole uncoveredMunicipalitiesHref?: string }) => { - const actions = toHomeActionButtonProps(homeActionsForRole(role), uncoveredMunicipalitiesHref) + const actions = toHomeActionButtonProps(homeActionsForRole(role), { + uncoveredMunicipalitiesHref, + returnPath: CAMPAIGN_HOME, + }) if (actions.length === 0) return null diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index ca4ed62d..4b609c43 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -22,7 +22,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes' import { wizardActionHref } from '@/lib/campaignActionRoutes' import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' -import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { WIZARD_LEADERSHIP_ADD_TILE_LABEL, WIZARD_LEADERSHIP_CONTINUE_LABEL, @@ -36,7 +35,7 @@ import { } from '@/lib/campaignWizardCopy' import { truncateNameAtWordBoundary } from '@/lib/leadershipNameTruncate' import { cn } from '@/lib/utils' -import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain' +import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain' import { resolveWizardLeadershipSkip, type WizardLeadershipTileViewModel, @@ -50,6 +49,7 @@ type WizardLeadershipStepProps = { entryAction?: CampaignWizardActionId initialTiles: WizardLeadershipTileViewModel[] initialLeadershipId?: number + returnPath?: string } type WizardLeadershipMode = @@ -70,6 +70,7 @@ export const WizardLeadershipStep = ({ entryAction, initialTiles, initialLeadershipId, + returnPath, }: WizardLeadershipStepProps) => { const router = useRouter() const [isContinuing, startContinueTransition] = useTransition() @@ -84,11 +85,12 @@ export const WizardLeadershipStep = ({ const [dirty, setDirty] = useState(false) const [infoTile, setInfoTile] = useState(null) - const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug) + const skipConfig = resolveWizardLeadershipSkip(entryAction, municipalitySlug, returnPath) const chainContinueHref = wizardChainContinueHref( resolveWizardChainEntry(entryAction, 'update-leadership'), 'update-leadership', municipalitySlug, + returnPath, ) const stepTitle = @@ -123,8 +125,8 @@ export const WizardLeadershipStep = ({ flowTitle={wizardFlowTitleForSlug(actionSlug)} stepTitle={stepTitle} isEntryStep={false} - previousHref={wizardActionHref(actionSlug)} - dismissHref={CAMPAIGN_HOME} + previousHref={wizardActionHref(actionSlug, municipalitySlug, { returnPath })} + dismissHref={wizardChainEndHref(returnPath)} municipalityLabel={municipalityName} skip={skipConfig} trailingAction={trailingAction} diff --git a/src/components/campaign/municipality/WizardSignalBodyStep.tsx b/src/components/campaign/municipality/WizardSignalBodyStep.tsx index a3313969..b23e9b2d 100644 --- a/src/components/campaign/municipality/WizardSignalBodyStep.tsx +++ b/src/components/campaign/municipality/WizardSignalBodyStep.tsx @@ -17,7 +17,7 @@ import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy' import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate' import { municipalitySignalTypeLabels } from '@/lib/schemas/municipalityUpdate' -import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain' +import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain' import { resolveWizardSignalSkip, WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX, @@ -31,6 +31,7 @@ type WizardSignalBodyStepProps = { municipalitySlug: string signalType: MunicipalitySignalType entryAction?: CampaignWizardActionId + returnPath?: string } export const WizardSignalBodyStep = ({ @@ -40,19 +41,22 @@ export const WizardSignalBodyStep = ({ municipalitySlug, signalType, entryAction, + returnPath, }: WizardSignalBodyStepProps) => { const router = useRouter() const [state, submitAction, isPending] = useActionState( createMunicipalityListSignalFormAction, {}, ) - const skip = resolveWizardSignalSkip(entryAction, municipalitySlug) + const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath) const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}` useCampaignFormSuccessToast(state, () => { recordLastActedMunicipality(municipalitySlug) const sessionEntry = resolveWizardChainEntry(entryAction, 'register-signal') - router.replace(wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug)) + router.replace( + wizardChainContinueHref(sessionEntry, 'register-signal', municipalitySlug, returnPath), + ) }) return ( @@ -60,7 +64,14 @@ export const WizardSignalBodyStep = ({ flowTitle={wizardFlowTitleForSlug(actionSlug)} isEntryStep={false} stepTitle={stepTitle} - previousHref={wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)} + previousHref={wizardSignalHref( + actionSlug, + municipalitySlug, + undefined, + entryAction, + returnPath, + )} + dismissHref={wizardChainEndHref(returnPath)} municipalityLabel={municipalityName} skip={skip} trailingAction={skip ? : undefined} diff --git a/src/components/campaign/municipality/WizardSignalTypeStep.tsx b/src/components/campaign/municipality/WizardSignalTypeStep.tsx index 9af18f38..61c0bf30 100644 --- a/src/components/campaign/municipality/WizardSignalTypeStep.tsx +++ b/src/components/campaign/municipality/WizardSignalTypeStep.tsx @@ -26,12 +26,14 @@ import { import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate' import { cn } from '@/lib/utils' import { resolveWizardSignalSkip, WIZARD_SIGNAL_TYPE_STEP_TITLE } from '@/lib/wizardSignalUi' +import { wizardChainEndHref } from '@/lib/wizardActionChain' type WizardSignalTypeStepProps = { actionSlug: string municipalityName: string municipalitySlug: string entryAction?: CampaignWizardActionId + returnPath?: string } export const WizardSignalTypeStep = ({ @@ -39,10 +41,11 @@ export const WizardSignalTypeStep = ({ municipalityName, municipalitySlug, entryAction, + returnPath, }: WizardSignalTypeStepProps) => { const [infoType, setInfoType] = useState(null) const infoEntry = infoType ? municipalitySignalTypeMetaByType[infoType] : null - const skip = resolveWizardSignalSkip(entryAction, municipalitySlug) + const skip = resolveWizardSignalSkip(entryAction, municipalitySlug, returnPath) return ( <> @@ -50,7 +53,8 @@ export const WizardSignalTypeStep = ({ flowTitle={wizardFlowTitleForSlug(actionSlug)} isEntryStep={false} stepTitle={WIZARD_SIGNAL_TYPE_STEP_TITLE} - previousHref={wizardActionHref(actionSlug)} + previousHref={wizardActionHref(actionSlug, municipalitySlug, { returnPath })} + dismissHref={wizardChainEndHref(returnPath)} municipalityLabel={municipalityName} contentAlign="end" skip={skip} @@ -62,7 +66,13 @@ export const WizardSignalTypeStep = ({ return (
  • + returnPath?: string } export const WizardTrendChoiceStep = ({ @@ -47,9 +49,10 @@ export const WizardTrendChoiceStep = ({ currentStatus, entryAction, prefillExtraParams, + returnPath, }: WizardTrendChoiceStepProps) => { const [infoEntry, setInfoEntry] = useState(null) - const skip = resolveWizardTrendSkip(entryAction, municipalitySlug) + const skip = resolveWizardTrendSkip(entryAction, municipalitySlug, returnPath) const options = selectablePoliticalTrendStatuses(currentStatus) return ( @@ -58,7 +61,8 @@ export const WizardTrendChoiceStep = ({ flowTitle={wizardFlowTitleForSlug(actionSlug)} isEntryStep={false} stepTitle={wizardTrendChoiceStepTitle(currentStatus)} - previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction })} + previousHref={wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath })} + dismissHref={wizardChainEndHref(returnPath)} municipalityLabel={municipalityName} skip={skip} trailingAction={skip ? : undefined} @@ -76,6 +80,7 @@ export const WizardTrendChoiceStep = ({ status, entryAction, prefillExtraParams, + returnPath, )} className={cn( 'flex aspect-square w-full flex-col justify-between rounded-lg border bg-background p-3 pr-10 text-left', diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx index f416d24d..40ed2a71 100644 --- a/src/components/campaign/municipality/WizardTrendNoteStep.tsx +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -21,7 +21,7 @@ import { WIZARD_TREND_SAVE_LABEL, } from '@/lib/politicalTrendWizardUi' import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality' -import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain' +import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain' import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels' type WizardTrendNoteStepProps = { @@ -32,6 +32,7 @@ type WizardTrendNoteStepProps = { trendStatus: PoliticalTrendStatusValue initialNote: string entryAction?: CampaignWizardActionId + returnPath?: string } export const WizardTrendNoteStep = ({ @@ -42,6 +43,7 @@ export const WizardTrendNoteStep = ({ trendStatus, initialNote, entryAction, + returnPath, }: WizardTrendNoteStepProps) => { const router = useRouter() const [note, setNote] = useState(initialNote) @@ -49,13 +51,15 @@ export const WizardTrendNoteStep = ({ setMunicipalityPoliticalTrendFormAction, {}, ) - const skip = resolveWizardTrendSkip(entryAction, municipalitySlug) + 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.replace(wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug)) + router.replace( + wizardChainContinueHref(sessionEntry, 'change-trend', municipalitySlug, returnPath), + ) }) return ( @@ -63,7 +67,15 @@ export const WizardTrendNoteStep = ({ flowTitle={wizardFlowTitleForSlug(actionSlug)} isEntryStep={false} stepTitle={stepTitle} - previousHref={wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction)} + previousHref={wizardTrendHref( + actionSlug, + municipalitySlug, + undefined, + entryAction, + undefined, + returnPath, + )} + dismissHref={wizardChainEndHref(returnPath)} municipalityLabel={municipalityName} skip={skip} trailingAction={skip ? : undefined} diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index 5d2438a8..f3d44ddc 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -19,7 +19,6 @@ import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes' import { wizardActionHref } from '@/lib/campaignActionRoutes' import { postCampaignJson } from '@/lib/campaignJsonRequest' import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' -import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { resolveWizardVotesSkip, WIZARD_VOTES_FINAL_CTA_LABEL, @@ -28,7 +27,7 @@ import { wizardNextStepTitle, } from '@/lib/campaignWizardCopy' import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' -import { resolveWizardChainEntry, wizardChainContinueHref } from '@/lib/wizardActionChain' +import { resolveWizardChainEntry, wizardChainContinueHref, wizardChainEndHref } from '@/lib/wizardActionChain' import { applyVoteShortcut, getWizardVoteViolation, @@ -45,6 +44,7 @@ type WizardExpectedVotesStepProps = { municipalitySlug: string initialExpectedVotes: VoteEstimateScenarioViewModel entryAction?: CampaignWizardActionId + returnPath?: string } export const WizardExpectedVotesStep = ({ @@ -54,6 +54,7 @@ export const WizardExpectedVotesStep = ({ municipalitySlug, initialExpectedVotes, entryAction, + returnPath, }: WizardExpectedVotesStepProps) => { const router = useRouter() const [isPending, startTransition] = useTransition() @@ -61,7 +62,7 @@ export const WizardExpectedVotesStep = ({ const [focusedScenario, setFocusedScenario] = useState('central') const [violation, setViolation] = useState(null) const [saveError, setSaveError] = useState(null) - const skip = resolveWizardVotesSkip(entryAction, municipalitySlug) + const skip = resolveWizardVotesSkip(entryAction, municipalitySlug, returnPath) const clearViolationState = () => { if (violation) setViolation(null) @@ -80,7 +81,9 @@ export const WizardExpectedVotesStep = ({ const continueAfterVotes = () => { const sessionEntry = resolveWizardChainEntry(entryAction, 'update-votes') - router.replace(wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug)) + router.replace( + wizardChainContinueHref(sessionEntry, 'update-votes', municipalitySlug, returnPath), + ) } const handleConfirm = () => { @@ -131,8 +134,8 @@ export const WizardExpectedVotesStep = ({ flowTitle={wizardFlowTitleForSlug(actionSlug)} stepTitle={wizardNextStepTitle(actionSlug)} isEntryStep={false} - previousHref={wizardActionHref(actionSlug)} - dismissHref={CAMPAIGN_HOME} + previousHref={wizardActionHref(actionSlug, municipalitySlug, { returnPath })} + dismissHref={wizardChainEndHref(returnPath)} municipalityLabel={municipalityName} skip={skip} trailingAction={trailingAction} diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx index b4555abe..64bf62f9 100644 --- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx +++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx @@ -53,6 +53,7 @@ type WizardSearchResultsState = type WizardMunicipalitySearchStepProps = { actionSlug: string previousHref: string + returnPath?: string accessibleMunicipalities: AccessibleMunicipality[] } @@ -72,6 +73,7 @@ const buildSuggestSuccessState = ( export const WizardMunicipalitySearchStep = ({ actionSlug, previousHref, + returnPath, accessibleMunicipalities, }: WizardMunicipalitySearchStepProps) => { const { query, setRaw, isDebouncing } = useHomeSearchQuery() @@ -216,7 +218,7 @@ export const WizardMunicipalitySearchStep = ({ {displayRows.map(({ hit, continuityReason }) => (
  • (

    {wizardNextStepPlaceholder(actionSlug)}

    diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts index daeb943c..9565a1c5 100644 --- a/src/lib/activityQuickActions.ts +++ b/src/lib/activityQuickActions.ts @@ -43,6 +43,7 @@ const listQuickActions = (): readonly CampaignQuickAction[] => [ const wizardActionsWithMunicipalityPrefill = ( role: CampaignRole, municipalitySlug: string, + returnPath: string, ): readonly CampaignQuickAction[] => { const homeActions = homeActionsForRole(role).filter((action) => WIZARD_ACTION_IDS.includes(action.id as CampaignWizardActionId), @@ -55,7 +56,9 @@ const wizardActionsWithMunicipalityPrefill = ( label: action.label, icon: action.icon, description: action.description, - href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[wizardId], municipalitySlug), + href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[wizardId], municipalitySlug, { + returnPath, + }), } }) } @@ -93,6 +96,7 @@ export const resolveActivityQuickActions = ( surface: ActivityQuickActionSurface, role: CampaignRole, context: CampaignQuickActionContext, + pathname?: string, ): readonly CampaignQuickAction[] => { if (!isStaffCampaignRole(role)) return [] @@ -102,6 +106,7 @@ export const resolveActivityQuickActions = ( const activitySlug = context.activitySlug ?? surface.activitySlug const municipalitySlug = context.municipalitySlug + const returnPath = pathname ?? `/campanha/atividades/${activitySlug}` const detailActions = detailVerticalQuickActions(activitySlug) @@ -109,5 +114,8 @@ export const resolveActivityQuickActions = ( return detailActions } - return [...wizardActionsWithMunicipalityPrefill(role, municipalitySlug), ...detailActions] + return [ + ...wizardActionsWithMunicipalityPrefill(role, municipalitySlug, returnPath), + ...detailActions, + ] } diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts index ea2e9ffa..01854e1b 100644 --- a/src/lib/campaignActionRoutes.ts +++ b/src/lib/campaignActionRoutes.ts @@ -67,9 +67,50 @@ 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 + +const WIZARD_RETURN_PATH_AUTH_PREFIXES = [ + '/campanha/login', + '/campanha/convite', + '/campanha/redefinir-senha', + '/campanha/webauthn', +] as const + +/** Allowlisted internal return paths for wizard dismiss / chain end (B110). */ +export const isWizardReturnPath = (pathname: string): boolean => { + if (!pathname.startsWith('/campanha')) return false + if (pathname.startsWith('/campanha/acoes')) return false + for (const prefix of WIZARD_RETURN_PATH_AUTH_PREFIXES) { + if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return false + } + return true +} + +export const parseWizardReturnPath = ( + value: string | string[] | undefined, +): string | undefined => { + const raw = Array.isArray(value) ? value[0] : value + const trimmed = raw?.trim() + if (!trimmed) return undefined + if (trimmed.includes('://') || trimmed.startsWith('//')) return undefined + const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + if (path.includes('?') || path.includes('#')) return undefined + return isWizardReturnPath(path) ? path : undefined +} + +export const appendWizardReturnPath = (href: string, returnPath?: string): string => { + if (!returnPath || !isWizardReturnPath(returnPath)) return href + const queryIndex = href.indexOf('?') + const base = queryIndex === -1 ? href : href.slice(0, queryIndex) + const params = new URLSearchParams(queryIndex === -1 ? '' : href.slice(queryIndex + 1)) + params.set(WIZARD_RETURN_PATH_QUERY_KEY, returnPath) + return `${base}?${params.toString()}` +} + export type WizardActionHrefOptions = { entryAction?: CampaignWizardActionId leadershipId?: number + returnPath?: string } export const wizardActionHref = ( @@ -79,7 +120,7 @@ export const wizardActionHref = ( ): string => { const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}` if (!municipalitySlug && !options?.leadershipId && !options?.entryAction) { - return base + return appendWizardReturnPath(base, options?.returnPath) } const params = new URLSearchParams() if (municipalitySlug) { @@ -91,7 +132,8 @@ export const wizardActionHref = ( if (options?.leadershipId !== undefined) { params.set(WIZARD_LEADERSHIP_ID_QUERY_KEY, String(options.leadershipId)) } - return `${base}?${params.toString()}` + const href = params.size > 0 ? `${base}?${params.toString()}` : base + return appendWizardReturnPath(href, options?.returnPath) } export const parseWizardMunicipioParam = ( @@ -132,10 +174,11 @@ export const wizardSignalHref = ( municipalitySlug?: string, signalType?: MunicipalitySignalType, entryAction?: CampaignWizardActionId, + returnPath?: string, ): string => { const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}` if (!municipalitySlug) { - return base + return appendWizardReturnPath(base, returnPath) } const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug }) @@ -145,7 +188,7 @@ export const wizardSignalHref = ( if (entryAction) { params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction) } - return `${base}?${params.toString()}` + return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath) } export const resolveWizardSignalTypeParam = ( @@ -171,10 +214,11 @@ export const wizardTrendHref = ( trendStatus?: PoliticalTrendStatusValue, entryAction?: CampaignWizardActionId, extraParams?: Record, + returnPath?: string, ): string => { const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}` if (!municipalitySlug) { - return base + return appendWizardReturnPath(base, returnPath) } const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug }) @@ -189,7 +233,7 @@ export const wizardTrendHref = ( if (value) params.set(key, value) } } - return `${base}?${params.toString()}` + return appendWizardReturnPath(`${base}?${params.toString()}`, returnPath) } export const resolveWizardTrendStatusParam = ( diff --git a/src/lib/campaignHomeActions.ts b/src/lib/campaignHomeActions.ts index a4ed9def..a5ff80fb 100644 --- a/src/lib/campaignHomeActions.ts +++ b/src/lib/campaignHomeActions.ts @@ -13,6 +13,7 @@ import { import { CAMPAIGN_WIZARD_ACTION_SLUGS, campaignActionEntryHref, + appendWizardReturnPath, isCampaignWizardActionId, wizardActionHref, type CampaignWizardActionId, @@ -132,6 +133,7 @@ export const UNCOVERED_MUNICIPALITIES_LIST_HREF = export type HomeActionButtonPropsOptions = { uncoveredMunicipalitiesHref?: string municipalitySlug?: string + returnPath?: string } /** Staff Início catalog with hrefs — shared by quick-action registries (B80+). */ @@ -139,14 +141,20 @@ const staffHomeQuickActionsByRole = new Map { if (!isStaffCampaignRole(role)) return [] - const cached = staffHomeQuickActionsByRole.get(role) - if (cached) return cached + if (!returnPath) { + const cached = staffHomeQuickActionsByRole.get(role) + if (cached) return cached + } const actions = toHomeActionButtonProps(homeActionsForRole(role), { uncoveredMunicipalitiesHref: UNCOVERED_MUNICIPALITIES_LIST_HREF, + returnPath, }) - staffHomeQuickActionsByRole.set(role, actions) + if (!returnPath) { + staffHomeQuickActionsByRole.set(role, actions) + } return actions } @@ -164,9 +172,13 @@ export const toHomeActionButtonProps = ( } else if (action.id === 'uncovered-municipalities') { href = resolved.uncoveredMunicipalitiesHref } else if (isCampaignWizardActionId(action.id)) { - href = resolved.municipalitySlug - ? wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[action.id], resolved.municipalitySlug) - : campaignActionEntryHref(action.id) + if (resolved.municipalitySlug) { + href = wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[action.id], resolved.municipalitySlug, { + returnPath: resolved.returnPath, + }) + } else { + href = appendWizardReturnPath(campaignActionEntryHref(action.id), resolved.returnPath) + } } return { ...action, diff --git a/src/lib/campaignMunicipalityQuickActions.ts b/src/lib/campaignMunicipalityQuickActions.ts index 194b9366..0fd76537 100644 --- a/src/lib/campaignMunicipalityQuickActions.ts +++ b/src/lib/campaignMunicipalityQuickActions.ts @@ -22,18 +22,20 @@ export const parseMunicipalityDetailSlug = (pathname: string): string | undefine export const resolveMunicipalityListQuickActions = ( role: CampaignRole, -): readonly ResolvedCampaignHomeAction[] => resolveStaffHomeQuickActions(role) + returnPath?: string, +): readonly ResolvedCampaignHomeAction[] => resolveStaffHomeQuickActions(role, returnPath) export const resolveMunicipalityDetailQuickActions = ( role: CampaignRole, municipalitySlug: string, + returnPath?: string, ): readonly ResolvedCampaignHomeAction[] => { if (!isStaffCampaignRole(role)) return [] const actions = homeActionsForRole(role).filter( (action) => action.id !== 'uncovered-municipalities', ) - return toHomeActionButtonProps(actions, { municipalitySlug }) + return toHomeActionButtonProps(actions, { municipalitySlug, returnPath }) } export const resolveMunicipalityQuickActionsForPath = ( @@ -42,12 +44,12 @@ export const resolveMunicipalityQuickActionsForPath = ( context: CampaignQuickActionContext, ): readonly ResolvedCampaignHomeAction[] => { if (isMunicipalitiesListPath(pathname)) { - return resolveMunicipalityListQuickActions(role) + return resolveMunicipalityListQuickActions(role, pathname) } const slug = context.municipalitySlug ?? parseMunicipalityDetailSlug(pathname) if (slug) { - return resolveMunicipalityDetailQuickActions(role, slug) + return resolveMunicipalityDetailQuickActions(role, slug, pathname) } return [] diff --git a/src/lib/campaignQuickActionDemands.ts b/src/lib/campaignQuickActionDemands.ts index bd53d1e4..a8005f31 100644 --- a/src/lib/campaignQuickActionDemands.ts +++ b/src/lib/campaignQuickActionDemands.ts @@ -66,6 +66,7 @@ export const resolveDemandsListQuickActions = ( export const resolveDemandDetailQuickActions = ( role: CampaignRole, context: CampaignQuickActionContext, + returnPath: string, ): readonly CampaignQuickAction[] => { if (!isStaffCampaignRole(role)) { return [] @@ -93,7 +94,9 @@ export const resolveDemandDetailQuickActions = ( return [ { ...action, - href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[id], municipalitySlug), + href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[id], municipalitySlug, { + returnPath, + }), }, ] }) diff --git a/src/lib/campaignQuickActionDobradinhas.ts b/src/lib/campaignQuickActionDobradinhas.ts index 6b84396c..dc6ed3be 100644 --- a/src/lib/campaignQuickActionDobradinhas.ts +++ b/src/lib/campaignQuickActionDobradinhas.ts @@ -42,7 +42,7 @@ export const resolveDobradinhasQuickActions = ( return [] } - const staffActions = resolveStaffHomeQuickActions(role) + const staffActions = resolveStaffHomeQuickActions(role, pathname) if (isStateDeputyListPath(pathname)) { return [newStateDeputyAction(), ...staffActions] diff --git a/src/lib/campaignQuickActionLeadership.ts b/src/lib/campaignQuickActionLeadership.ts index 143a9466..851f445e 100644 --- a/src/lib/campaignQuickActionLeadership.ts +++ b/src/lib/campaignQuickActionLeadership.ts @@ -1,5 +1,6 @@ import { CAMPAIGN_WIZARD_ACTION_SLUGS, + appendWizardReturnPath, campaignActionEntryHref, wizardActionHref, type CampaignWizardActionId, @@ -34,40 +35,46 @@ const parseLeadershipDetailId = (pathname: string): number | undefined => { const resolveLeadershipListActions = ( role: CampaignRole, + returnPath: string, ): readonly ResolvedCampaignHomeAction[] => { const actions = homeActionsForRole(role).filter((action) => action.id === 'update-leadership') return actions.map((action) => ({ ...action, - href: campaignActionEntryHref('update-leadership'), + href: appendWizardReturnPath(campaignActionEntryHref('update-leadership'), returnPath), })) } const hrefForLeadershipDetailAction = ( action: CampaignHomeAction, context: CampaignQuickActionContext, + returnPath: string, ): string => { const wizardSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action.id as CampaignWizardActionId] const { municipalitySlug, leadershipId } = context if (action.id === 'update-leadership') { if (municipalitySlug && leadershipId !== undefined) { - return wizardActionHref(wizardSlug, municipalitySlug, { leadershipId }) + return wizardActionHref(wizardSlug, municipalitySlug, { leadershipId, returnPath }) } if (leadershipId !== undefined) { return `${LEADERSHIP_LIST_PATH}/${leadershipId}` } - return campaignActionEntryHref('update-leadership') + return appendWizardReturnPath(campaignActionEntryHref('update-leadership'), returnPath) } if (municipalitySlug) { - return wizardActionHref(wizardSlug, municipalitySlug) + return wizardActionHref(wizardSlug, municipalitySlug, { returnPath }) } - return campaignActionEntryHref(action.id as CampaignWizardActionId) + return appendWizardReturnPath( + campaignActionEntryHref(action.id as CampaignWizardActionId), + returnPath, + ) } const resolveLeadershipDetailActions = ( role: CampaignRole, context: CampaignQuickActionContext, + returnPath: string, ): readonly ResolvedCampaignHomeAction[] => { const actions = homeActionsForRole(role).filter((action) => LEADERSHIP_DETAIL_WIZARD_IDS.includes(action.id as CampaignWizardActionId), @@ -75,7 +82,7 @@ const resolveLeadershipDetailActions = ( return actions.map((action) => ({ ...action, - href: hrefForLeadershipDetailAction(action, context), + href: hrefForLeadershipDetailAction(action, context, returnPath), })) } @@ -87,7 +94,7 @@ export const resolveLeadershipQuickActions = ( if (!isStaffCampaignRole(role)) return null if (isLeadershipListPath(pathname)) { - return resolveLeadershipListActions(role) + return resolveLeadershipListActions(role, pathname) } const leadershipId = parseLeadershipDetailId(pathname) @@ -95,7 +102,7 @@ export const resolveLeadershipQuickActions = ( return resolveLeadershipDetailActions(role, { ...context, leadershipId: context.leadershipId ?? leadershipId, - }) + }, pathname) } return null diff --git a/src/lib/campaignQuickActionRegistry.ts b/src/lib/campaignQuickActionRegistry.ts index b35ab409..695b475f 100644 --- a/src/lib/campaignQuickActionRegistry.ts +++ b/src/lib/campaignQuickActionRegistry.ts @@ -48,7 +48,7 @@ export const resolveQuickActionsForPath = ( const activitySurface = parseActivityQuickActionSurface(pathname) if (activitySurface) { - return resolveActivityQuickActions(activitySurface, role, context) + return resolveActivityQuickActions(activitySurface, role, context, pathname) } const organizationSurface = parseOrganizationQuickActionSurface(pathname) @@ -60,7 +60,7 @@ export const resolveQuickActionsForPath = ( return resolveDemandsListQuickActions(role) } if (isDemandDetailPath(pathname)) { - return resolveDemandDetailQuickActions(role, context) + return resolveDemandDetailQuickActions(role, context, pathname) } const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context) @@ -79,7 +79,7 @@ export const resolveQuickActionsForPath = ( if (advisorActions.length > 0) return advisorActions if (isTerritoriesListPath(pathname)) { - return resolveStaffHomeQuickActions(role) + return resolveStaffHomeQuickActions(role, pathname) } const referenceActions = resolveReferenceQuickActionsForPath(pathname, role, context) diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts index f340ff64..d47e99fa 100644 --- a/src/lib/campaignWizardCopy.ts +++ b/src/lib/campaignWizardCopy.ts @@ -49,6 +49,7 @@ const shouldShowWizardVotesSkip = (entryAction: CampaignWizardActionId | undefin export const resolveWizardVotesSkip = ( entryAction: CampaignWizardActionId | undefined, municipalitySlug: string, + returnPath?: string, ): WizardVotesSkipAction | undefined => shouldShowWizardVotesSkip(entryAction) ? { @@ -57,6 +58,7 @@ export const resolveWizardVotesSkip = ( resolveWizardChainEntry(entryAction, 'update-votes'), 'update-votes', municipalitySlug, + returnPath, ), } : undefined diff --git a/src/lib/politicalTrendWizardUi.ts b/src/lib/politicalTrendWizardUi.ts index 75a81f66..45242b40 100644 --- a/src/lib/politicalTrendWizardUi.ts +++ b/src/lib/politicalTrendWizardUi.ts @@ -69,6 +69,7 @@ export const shouldShowWizardTrendSkip = ( export const resolveWizardTrendSkip = ( entryAction: CampaignWizardActionId | undefined, municipalitySlug: string, + returnPath?: string, ): WizardTrendSkipAction | undefined => shouldShowWizardTrendSkip(entryAction) ? { @@ -77,6 +78,7 @@ export const resolveWizardTrendSkip = ( resolveWizardChainEntry(entryAction, 'change-trend'), 'change-trend', municipalitySlug, + returnPath, ), } : undefined diff --git a/src/lib/wizardActionChain.ts b/src/lib/wizardActionChain.ts index bb68d083..ff4a63c7 100644 --- a/src/lib/wizardActionChain.ts +++ b/src/lib/wizardActionChain.ts @@ -6,6 +6,7 @@ import { CAMPAIGN_WIZARD_ACTION_SLUGS, type CampaignWizardActionId, + isWizardReturnPath, wizardActionHref, wizardSignalHref, wizardTrendHref, @@ -54,16 +55,17 @@ 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': - return wizardActionHref(actionSlug, municipalitySlug, { entryAction }) + return wizardActionHref(actionSlug, municipalitySlug, { entryAction, returnPath }) case 'register-signal': - return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction) + return wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction, returnPath) case 'change-trend': - return wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction) + return wizardTrendHref(actionSlug, municipalitySlug, undefined, entryAction, undefined, returnPath) default: { const exhaustive: never = action return exhaustive @@ -71,23 +73,28 @@ const wizardHrefForChainStep = ( } } +/** 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 Início when the queue is empty / entry is not chainable. + * 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 CAMPAIGN_HOME + return wizardChainEndHref(returnPath) } const next = nextWizardChainStep(entryAction, completedAction) if (!next) { - return CAMPAIGN_HOME + return wizardChainEndHref(returnPath) } - return wizardHrefForChainStep(next, municipalitySlug, entryAction) + return wizardHrefForChainStep(next, municipalitySlug, entryAction, returnPath) } /** Session owner for navigation: explicit `entry` query, else the current principal. */ diff --git a/src/lib/wizardLeadershipContract.ts b/src/lib/wizardLeadershipContract.ts index 158b51ed..283734c8 100644 --- a/src/lib/wizardLeadershipContract.ts +++ b/src/lib/wizardLeadershipContract.ts @@ -27,6 +27,7 @@ export const showLeadershipWizardSkip = (entryAction?: CampaignWizardActionId): export const resolveWizardLeadershipSkip = ( entryAction: CampaignWizardActionId | undefined, municipalitySlug: string, + returnPath?: string, ): WizardLeadershipSkipAction | undefined => showLeadershipWizardSkip(entryAction) ? { @@ -35,6 +36,7 @@ export const resolveWizardLeadershipSkip = ( resolveWizardChainEntry(entryAction, 'update-leadership'), 'update-leadership', municipalitySlug, + returnPath, ), } : undefined diff --git a/src/lib/wizardSignalUi.ts b/src/lib/wizardSignalUi.ts index c1c5d9e3..16de7458 100644 --- a/src/lib/wizardSignalUi.ts +++ b/src/lib/wizardSignalUi.ts @@ -22,6 +22,7 @@ export const shouldShowWizardSignalSkip = ( export const resolveWizardSignalSkip = ( entryAction: CampaignWizardActionId | undefined, municipalitySlug: string, + returnPath?: string, ): WizardSignalSkipAction | undefined => shouldShowWizardSignalSkip(entryAction) ? { @@ -30,6 +31,7 @@ export const resolveWizardSignalSkip = ( resolveWizardChainEntry(entryAction, 'register-signal'), 'register-signal', municipalitySlug, + returnPath, ), } : undefined diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index c6c09fa6..b27deeb3 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -218,7 +218,7 @@ describe('campaignQuickActionDobradinhas (B83)', () => { expect(actions.map((action) => action.id)).toEqual(['new-state-deputy', ...staffActionIds]) expect(actions[0]?.href).toBe('/campanha/dobradinhas/nova') expect(actions.find((action) => action.id === 'update-votes')?.href).toBe( - '/campanha/acoes/atualizar-votos', + '/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fdobradinhas', ) expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe( UNCOVERED_MUNICIPALITIES_LIST_HREF, @@ -279,7 +279,7 @@ describe('campaignQuickActionRegistry', () => { }) expect(detail).toHaveLength(5) expect(detail.find((action) => action.id === 'update-votes')?.href).toBe( - '/campanha/acoes/atualizar-votos?municipio=foo', + '/campanha/acoes/atualizar-votos?municipio=foo&from=%2Fcampanha%2Fmunicipios%2Ffoo', ) }) @@ -303,7 +303,7 @@ describe('campaignQuickActionRegistry', () => { ) const registerSignal = actions.find((action) => action.id === 'register-signal') expect(registerSignal?.href).toBe( - `/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01`, + `/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01&from=%2Fcampanha%2Fatividades%2Fevento-zona-1`, ) }) @@ -311,7 +311,7 @@ describe('campaignQuickActionRegistry', () => { const actions = resolveQuickActionsForPath('/campanha/territorios', 'coordinator', {}) expect(actions.map((action) => action.id)).toEqual(staffActionIds) expect(actions.find((action) => action.id === 'update-votes')?.href).toBe( - '/campanha/acoes/atualizar-votos', + '/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fterritorios', ) expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe( UNCOVERED_MUNICIPALITIES_LIST_HREF, @@ -346,7 +346,7 @@ describe('campaignQuickActionRegistry', () => { 'register-demand', ]) expect(actions.find((action) => action.id === 'update-votes')?.href).toBe( - '/campanha/acoes/atualizar-votos?municipio=cairu', + '/campanha/acoes/atualizar-votos?municipio=cairu&from=%2Fcampanha%2Fdemandas%2Fpedido-cairu', ) expect(actions.find((action) => action.id === 'register-demand')?.href).toBe( demandCreateHref(42), @@ -389,7 +389,11 @@ describe('campaignQuickActionDemands resolvers', () => { it('returns empty catalog for leader lockdown', () => { expect(resolveDemandsListQuickActions('leader')).toEqual([]) expect( - resolveDemandDetailQuickActions('leader', { municipalitySlug: 'cairu', municipalityId: 1 }), + resolveDemandDetailQuickActions( + 'leader', + { municipalitySlug: 'cairu', municipalityId: 1 }, + '/campanha/demandas/foo', + ), ).toEqual([]) }) }) diff --git a/tests/unit/campaignQuickActionLeadership.unit.spec.ts b/tests/unit/campaignQuickActionLeadership.unit.spec.ts index 4a8f6ea8..ccb63959 100644 --- a/tests/unit/campaignQuickActionLeadership.unit.spec.ts +++ b/tests/unit/campaignQuickActionLeadership.unit.spec.ts @@ -8,7 +8,9 @@ describe('resolveLeadershipQuickActions', () => { const actions = resolveLeadershipQuickActions('/campanha/liderancas', 'coordinator', {}) expect(actions).toHaveLength(1) expect(actions?.[0]?.id).toBe('update-leadership') - expect(actions?.[0]?.href).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca`) + expect(actions?.[0]?.href).toBe( + `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?from=%2Fcampanha%2Fliderancas`, + ) }) it('returns five wizard actions on detail with municipality prefill when N=1', () => { @@ -24,10 +26,10 @@ describe('resolveLeadershipQuickActions', () => { 'register-demand', ]) expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe( - `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`, + `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&from=%2Fcampanha%2Fliderancas%2F42`, ) expect(actions?.find((action) => action.id === 'update-leadership')?.href).toBe( - `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42`, + `${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42&from=%2Fcampanha%2Fliderancas%2F42`, ) }) @@ -39,7 +41,7 @@ describe('resolveLeadershipQuickActions', () => { '/campanha/liderancas/7', ) expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe( - `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`, + `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?from=%2Fcampanha%2Fliderancas%2F7`, ) }) diff --git a/tests/unit/wizardActionChain.unit.spec.ts b/tests/unit/wizardActionChain.unit.spec.ts index 8ca05a10..2ad6d416 100644 --- a/tests/unit/wizardActionChain.unit.spec.ts +++ b/tests/unit/wizardActionChain.unit.spec.ts @@ -72,6 +72,16 @@ describe('wizardActionChain', () => { 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-sinal?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-signal', 'update-votes')).toBe('register-signal') diff --git a/tests/unit/wizardLeadershipStep.unit.spec.ts b/tests/unit/wizardLeadershipStep.unit.spec.ts index b60b9623..5f106279 100644 --- a/tests/unit/wizardLeadershipStep.unit.spec.ts +++ b/tests/unit/wizardLeadershipStep.unit.spec.ts @@ -27,8 +27,12 @@ describe('resolveWizardLeadershipSkip', () => { expect(resolveWizardLeadershipSkip('update-leadership', 'cairu')).toBeUndefined() }) - it('points skip to Início when leadership is the last chained step', () => { - // In the v1 matrix, leadership is always the final chained elo. + 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/wizardReturnPath.unit.spec.ts b/tests/unit/wizardReturnPath.unit.spec.ts new file mode 100644 index 00000000..852b5c5f --- /dev/null +++ b/tests/unit/wizardReturnPath.unit.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { + appendWizardReturnPath, + isWizardReturnPath, + parseWizardReturnPath, + WIZARD_RETURN_PATH_QUERY_KEY, +} from '@/lib/campaignActionRoutes' +import { CAMPAIGN_HOME } from '@/lib/campaignPaths' + +describe('wizard return path (B110)', () => { + it('allowlists internal /campanha paths outside acoes and auth', () => { + expect(isWizardReturnPath('/campanha')).toBe(true) + expect(isWizardReturnPath('/campanha/municipios/cairu')).toBe(true) + expect(isWizardReturnPath('/campanha/liderancas/42')).toBe(true) + expect(isWizardReturnPath('/campanha/acoes/mudar-tendencia')).toBe(false) + expect(isWizardReturnPath('/campanha/login')).toBe(false) + expect(isWizardReturnPath('/campanha/convite/token')).toBe(false) + expect(isWizardReturnPath('https://evil.test/phish')).toBe(false) + expect(isWizardReturnPath('/public')).toBe(false) + }) + + it('parseWizardReturnPath fails closed on invalid values', () => { + expect(parseWizardReturnPath(undefined)).toBeUndefined() + expect(parseWizardReturnPath('/campanha/municipios/foo')).toBe('/campanha/municipios/foo') + expect(parseWizardReturnPath('/campanha/acoes/foo')).toBeUndefined() + expect(parseWizardReturnPath('//evil.test')).toBeUndefined() + expect(parseWizardReturnPath('/campanha/foo?x=1')).toBeUndefined() + }) + + it('appendWizardReturnPath adds the from query key', () => { + const href = appendWizardReturnPath( + '/campanha/acoes/mudar-tendencia?municipio=cairu', + '/campanha/municipios/cairu', + ) + expect(href).toContain(`${WIZARD_RETURN_PATH_QUERY_KEY}=`) + expect(href).toContain( + `${WIZARD_RETURN_PATH_QUERY_KEY}=${encodeURIComponent('/campanha/municipios/cairu')}`, + ) + }) + + it('appendWizardReturnPath ignores invalid return paths', () => { + expect(appendWizardReturnPath('/campanha/acoes/foo', '/campanha/acoes/bar')).toBe( + '/campanha/acoes/foo', + ) + }) + + it('CAMPAIGN_HOME is a valid return path', () => { + expect(parseWizardReturnPath(CAMPAIGN_HOME)).toBe(CAMPAIGN_HOME) + }) +})