Migrate Delete Route journey to GOV.UK Questions - #3698
Merged
Conversation
Continues the move off WebCommon.FormFlow onto GovUk.Questions.AspNetCore (DFE issue #369). Delete Route is a plain linear walk — Index redirects to Reason, which advances to CheckAnswers — so it follows Add Route rather than the hub-and-spoke path model Edit Route needed. - DeleteRouteJourneyCoordinator owns the page caption, the person id and CancelAsync; DeleteRouteState is plain data. - Index is [StartsJourney] and advances to Reason with SetAsFirstStep, so Reason is step 0 and the redirect page stays out of the path. - The check answers completeness guard is dropped: the page is only reachable by posting the reason page, which path validation already enforces. - Cancel becomes a form field rather than a handler — a distinct path would be an invalid step and redirect forever. - Change links use the library's returnUrl in place of fromCheckAnswers, and the existence filter moves into the folder's Conventions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hortha
approved these changes
Aug 6, 2026
4 tasks
gunndabad
added a commit
that referenced
this pull request
Aug 6, 2026
### Context Continues the move off `WebCommon.FormFlow` onto `GovUk.Questions.AspNetCore` (DFE issue #369). This one is Edit Route to professional status. Delete Route (#3698) landed while this was in review, so with this one merged no route journey is left on FormFlow — only Edit Induction. Edit Route is hub and spoke rather than a linear walk, so the path model is the part worth reading closely. It's a different answer to the same question #3685 dealt with, for a different journey shape. ### Changes proposed in this pull request **The detail page is a hub, and its questions are reachable from it.** The detail page offers the questions that apply to the route, and each one returns there. The library only pushes a step on a post, so a change link — a GET straight to a question — would be rejected as an invalid step and bounced back, a redirect loop. `EditRouteJourneyCoordinator` therefore overrides `StepIsValid` so the hub's questions are reachable, as `SignInJourneyCoordinator` does for `/oauth2/authorize`. It has to override `GetCurrentStep` too: `ValidateJourneyFilter` asks for the current step *before* validating it, and the base implementation only knows about steps in the path, so overriding validation alone changes nothing. That was the single biggest cause of test failures while building this. Only the questions the route and its status actually ask are accepted — a question that doesn't apply isn't reachable at all. Because a question isn't a step in the path it can't push the next one, so navigation between questions is by plain redirect; only the detail page advances the path, to check answers. **Check answers keeps its completeness guard**, unlike Add Route. The hub lets the user go straight there at any point, so the guard is what asks for the reason for the change and for anything the route needs that its answers don't cover. In Add Route the linear walk made the same guard unreachable and it was removed; here it's load bearing. **Completing a route.** Moving a route to 'holds' asks when it was first held and whether it carries an induction exemption, buffered in `EditStatusState` until the last of them is answered, so abandoning half way leaves the route as it was. That sequence is now owned by the coordinator rather than spread across the status, holds from and induction exemption pages. **`EditRoutePage` replaces the shared `AddRoutePage`**, with its own requirement mapping, so this journey no longer reaches into the Add Route coordinator. There's no question for the route type itself — you can't change it when editing — so the two enums were never the same set. **`EditRouteCommonPageModel` is gone**, with the shared behaviour on the coordinator, and `EditRouteState` is plain data. Pages validate with FluentValidation through `ThrowIfInvalidAsync`, and the date inputs carry their error message prefix on the tag helper. ### Guidance to review Three things I'd particularly like a second opinion on: 1. **`CheckRouteToProfessionalStatusExistsFilterFactory` now implements `IOrderedFilter` at -200**, exactly as `CheckPersonExistsFilterFactory` already did. Without it the filter ran *after* the journey filter, so the coordinator couldn't seed its state from the route. It's a shared file — Delete Route applies the same factory from its `Conventions` — though running an existence check earlier should be harmless. #3698 deliberately left the file alone so it would merge cleanly, which it did. 2. **The applicable pages are cached on the journey state** (`AvailablePages`), recomputed when the journey starts and whenever the status changes. Step validation runs synchronously in the journey filter and can't await the route's reference data, and I didn't want to introduce sync-over-async there. The alternative is an applicability check inside each page's handler, where async is available, at the cost of a line per page. 3. **`_RouteDetail.cshtml` stays a partial here**, unlike Add Route where it was inlined, because both the detail page and check answers render it. Detail's "Continue" changed from a link to a submit button so that it can advance the path. Two changes to test content are worth reading rather than skimming. The old `EditRouteStateBuilder` is gone in favour of object initialisers plus an `EditRouteTestBase`; while converting it, note that `WithStatus` had been setting `CurrentStatus` as well, which matters because that pair is what decides whether the journey is completing a route. And the theory rows that exercised a question the route doesn't ask now assert that the page isn't reachable, rather than asserting how its heading reads. A few tests picked a route and status at random without requiring that the page under test applies, which flakes between 200 and 302 now that paths are validated. I constrained the ones that failed; there may be others that currently pass by luck. **Rebased over #3698.** The one conflict was `EditRoute/PermissionsTests`, exactly where #3698 said it would be and resolved the way it proposed: this branch's Edit Route arm, `main`'s Delete Route arm. The class now derives from `EditRouteTestBase` for the former and keeps #3698's private `CreateDeleteRouteJourneyInstanceAsync` for the latter — it can only inherit one of the two test bases. Both journeys are on the library now, so the comment about the other one still being on FormFlow is gone. Verified locally: | Suite | Result | | --- | --- | | SupportUi.Tests | 3865 passed, 3 skipped | | SupportUi.EndToEndTests | 142 passed | | SupportUi.EndToEndTests, `RoutesToProfessionalStatus` | 26 passed | One of three full SupportUi.Tests runs after the rebase reported a single failure; the other two were clean and I didn't capture its name, so I can't say more than that it wasn't reproducible. No emitted events change, so `docs/process-type-events.md` is untouched. ### Checklist - [ ] Attach to Trello card - [x] Rebased master - [x] Cleaned commit history - [x] Tested by running locally Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
4 tasks
gunndabad
added a commit
that referenced
this pull request
Aug 6, 2026
### Context Continues the move off `WebCommon.FormFlow` onto `GovUk.Questions.AspNetCore` (DFE issue #369). This is Edit Induction — the last SupportUi *journey* on FormFlow. It doesn't let us delete FormFlow yet. After this lands, what's left is Edit Route (#3691) and Delete Route (#3698), both migrated but still open; the shared plumbing (`GlobalUsings`, `LinkGeneratorExtensions.GetRequiredPathByPage`, `HttpContextCurrentUserIdProvider`, `AddFormFlow`, `TestBase`/`HostFixture`/`UiTestCommon`, and `WebCommon.Tests/FormFlow`); and three files under `AuthorizeAccess/Infrastructure/FormFlow` that are **already dead** — nothing references them. The deletion is a follow-up branch once the two route PRs merge. ### Changes proposed in this pull request **The four entry points keep their URLs.** The induction page links straight into Status, Exemption reasons, Start date and Completed date. `ValidateJourneyFilter` only consults `StartsJourney` when there's no instance, and `TryCreateNewInstanceAsync` seeds the new path with the requested URL as step 0 — so each of those pages can be both a way in and an ordinary step just by carrying `[StartsJourney]`. Nothing about the URLs changes, and **the question the user came in by is simply the path's first step**. That makes a lot of the journey's bookkeeping redundant: - **`JourneyStartPage`** and the **`Initialized`/`EnsureInitialized`** pair — the start page is read from the path and the seeding moves into `GetStartingStateAsync`. Check answers decides which answers offer a Change link from the path rather than a state field. - **`JourneyFromCheckAnswersPage`** — replaced by the library's `returnUrl`. It couldn't have survived as a query parameter anyway: anything other than `_jid`/`returnUrl` is part of the StepId, so it would fail path validation. - **`CommonJourneyPage`** and **`InductionJourneyPage`** — the shared behaviour is on `EditInductionJourneyCoordinator`; pages are plain `PageModel`s again. - **The four redirect-to-start-page guards.** Path validation covers a status that stops asking a question. The guards remain only for the case path validation can't see: a request naming a page directly as the journey's first step. One of those redirected to the page it was on — a latent loop — and now goes to the induction page. **`OnInvalidStep` returns the user to the question they came in by.** The default is the last step, which here is check answers, and landing there with a question unanswered just bounces them out again. **Going back to check answers doesn't push a step.** `PushStep` truncates everything after the current step, so advancing from an early question straight to check answers dropped the questions its other Change links point at. When the target is already in the path a plain redirect leaves the path intact. This is the same trap #3645 hit on Edit Details. **The status question is the exception to `returnUrl`.** Every other question sends the user back to check answers when they came from there; the status walks them forward through the questions that follow it even when they re-pick the same status, because those answers depend on it. That's today's behaviour, pinned by `EditInductionStatus_CYA_ChangeStatus_ContinueThroughJourneyToCYA`. ### Guidance to review **One back link changes.** Enter a start date from check answers that falls after the completed date, and you're asked for the completed date again — its Back now goes to check answers rather than back to the start date. `JourneyFromCheckAnswersPage.CheckAnswersToStartDate` encoded "back here, continue there", and one `returnUrl` can't express both. Happy to reinstate it with an explicit flag on the state if you'd rather keep it exactly as it was. **Page captions are now uniform** — `@Model.PageCaption`, "Edit induction details - {name}". Three pages said "Enter induction details" and `ExemptionReasons.cshtml` said "Enter induction **deetails**". Fixing the typo meant touching the copy, so I normalised all six rather than leave them inconsistent. **Tests.** They move onto `EditInductionTestBase`, which seeds the path the real journey would have built — which questions are in it depends on the start page and the status, so a few theory rows that described journeys that can't exist (a journey "starting at the start date" for a status that never asks for one) had their start page corrected. Per house style `EditInductionStateBuilder` and `CommonPageTests` are gone: state is built with object initialisers, the cross-page tests are folded into the page classes, and the access checks are in their own `PermissionsTests`. Three tests are new and worth reading: `Post_FromCheckAnswers_LeavesTheOtherQuestionsReachable` (the path-truncation regression, which only the browser tests caught first time round), `Post_Status_FromCheckAnswers_WhenNewStatusChangesTheQuestionsAsked_WalksForwardToTheNextQuestion`, and `Post_StartDate_FromCheckAnswers_AfterCompletedDate_AsksForCompletedDateWithBackLinkToStartDate`. Verified locally: | Suite | Result | | --- | --- | | SupportUi.Tests | 3876 passed, 3 skipped | | SupportUi.Tests, `EditInduction` | 396 passed | | SupportUi.EndToEndTests, `Induction` | 13 passed | Note for anyone else running these locally: intermittent `ChangeHistoryTests` / `ChangeLogProfessionalStatusEventsTests` failures during this work turned out to be the usual committed-data accumulation across repeated runs against one container, not this change — `just remove-tests-schema-cache` clears it. No emitted events change, so `docs/process-type-events.md` is untouched. ### Checklist - [ ] Attach to Trello card - [x] Rebased master - [x] Cleaned commit history - [x] Tested by running locally --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
Continues the move off
WebCommon.FormFlowontoGovUk.Questions.AspNetCore(DFE issue #369).This one is Delete Route to professional status.
Delete Route is the simplest of the three route journeys — a linear walk of Index → Reason →
Check answers — so it follows Add Route (#3685) rather than the hub-and-spoke path model Edit
Route (#3691) needed. Nothing here is novel; it's worth reading mainly for the two things I
deleted rather than ported.
Based on
main, not on #3691. The only file the two branches share isEditRoute/PermissionsTests, which covers both journeys — see below.Changes proposed in this pull request
DeleteRouteJourneyCoordinatorholds what the pages shared — the page caption, the personid, and
CancelAsync(delete the uploaded evidence file, drop the instance, return to therecord).
DeleteRouteStatelosesIRegisterJourney, itsInitializedflag and itsIsCompletecomputed property, and is now plain data.Index is
[StartsJourney]and advances to Reason withSetAsFirstStep, so Reason is step 0— its back link is null and falls back to the person's qualifications — and the redirect page
doesn't stay in the path.
The check answers completeness guard is gone. Unlike Edit Route, where the detail page is a
hub and the user can go straight to check answers at any point, here the page is reachable only
by posting the reason page, which path validation already enforces. Same reasoning as Add Route,
and the opposite conclusion to Edit Route — worth being explicit about, since the three journeys
now differ on this.
Cancel is a form field, not a handler. Both
/cancelhandler routes are gone, and with themthe
{handler?}segments from the two page routes: the library validates the request URL againstthe journey path, so a distinct path or query is an invalid step and redirects forever. The
buttons now POST to the same URL with
name="Cancel" value="true".Change links use the library's
returnUrlin place of thefromCheckAnswersflag, soDeleteRouteLinkGeneratordropsqualificationIdfrom its arguments — scoping route values comefrom
InstanceId.RouteValues— andCheckAnswersloads its reference data inOnPageHandlerExecutionAsyncrather than a separateOnGetAsync.CheckRouteToProfessionalStatusExistsFilterFactorymoves from per-page attributes into thefolder's
Conventions, matching Edit Route.Guidance to review
I did not take #3691's
IOrderedFilter/-200change toCheckRouteToProfessionalStatusExistsFilter. That change exists so Edit Route's coordinator canseed its state from the route before the journey filter runs. Delete Route's state is just the
reason, so
GetStartingState()needs nothing from the request, and the filter running at itsdefault order is fine. Leaving the shared file alone keeps these two branches from touching it —
whichever lands second, that file merges cleanly.
EditRoute/PermissionsTestswill conflict with #3691. It's a single file covering bothjourneys' pages, and both branches change how the delete journey is seeded in it. The conflict is
confined to one switch expression; I've kept the change as small as it can be. Whichever merges
second takes the other's Edit Route arm and keeps its own Delete Route arm.
Test content. The two page test classes move onto a new
DeleteRouteTestBasethat seeds thepath via
JourneyHelper; cancel tests POSTCancel=trueinstead of following aformaction; andjourney-deletion assertions read
IJourneyStateStoragerather thanJourneyInstance.Completed,since the library has no notion of completing an instance. I added back link coverage for both
pages, which nothing asserted before.
Verified locally:
RoutesToProfessionalStatusRoutesToProfessionalStatusTwo flakes worth naming, neither in Delete Route. One E2E run failed
EditRouteToProfessionalStatusTests.EditEachField_Cya_ShowsEditedContenton a training providername — it passes in isolation and on re-run, and looks like the usual committed-test-data
interaction. One full SupportUi.Tests run reported a single failure whose name I didn't capture
before two subsequent clean full runs.
No emitted events change, so
docs/process-type-events.mdis untouched.Checklist