diff --git a/ui/src/__tests__/components/FormBackLink.test.tsx b/ui/src/__tests__/components/FormBackLink.test.tsx index 4e3d6917..2a78c174 100644 --- a/ui/src/__tests__/components/FormBackLink.test.tsx +++ b/ui/src/__tests__/components/FormBackLink.test.tsx @@ -1,5 +1,4 @@ import "@testing-library/jest-dom"; - import { fireEvent, render, screen } from "@testing-library/react"; import { FormBackLink } from "@/components/FormBackLink"; @@ -12,6 +11,9 @@ const mockNavigationContext = { goBack: jest.fn(), canGoBack: jest.fn(() => true), clearHistory: jest.fn(), + resetNavigation: jest.fn(), + returnToStep: null, + setReturnToStep: jest.fn(), }; jest.mock("@/state", () => ({ @@ -109,7 +111,7 @@ describe("FormBackLink", () => { // BackLink should still be rendered but with empty text // Check for the back link container class - expect(document.querySelector('.nhsuk-back-link')).toBeInTheDocument(); + expect(document.querySelector(".nhsuk-back-link")).toBeInTheDocument(); }); }); diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx index fce5955b..1c542d63 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx @@ -46,6 +46,7 @@ jest.mock("@/hooks", () => ({ const goBackMock = jest.fn(); const goToStepMock = jest.fn(); +const resetNavigationMock = jest.fn(); const TestProvider = ({ children, @@ -77,6 +78,7 @@ const TestProvider = ({ goToStep: goToStepMock, canGoBack: () => history.length > 1, clearHistory: jest.fn(), + resetNavigation: resetNavigationMock, setReturnToStep: jest.fn(), }} > diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx index 1f3c2cfd..fe9e378e 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx @@ -1,4 +1,11 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useEffect } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { RoutePath } from "@/lib/models/route-paths"; +import orderService from "@/lib/services/order-service"; import { TestErrorBoundary } from "@/lib/test-utils/TestErrorBoundary"; +import CheckYourAnswersPage from "@/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage"; import { AuthProvider, AuthUser, @@ -6,16 +13,25 @@ import { useAuth, useCreateOrderContext, } from "@/state"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import CheckYourAnswersPage from "@/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage"; -import { MemoryRouter } from "react-router-dom"; -import orderService from "@/lib/services/order-service"; -import { useEffect } from "react"; +const mockNavigate = jest.fn(); +const mockClearAddresses = jest.fn(); +let mockNavigationType = "PUSH"; + +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom"); + + return { + ...actual, + useNavigate: () => mockNavigate, + useNavigationType: () => mockNavigationType, + }; +}); const mockGoToStep = jest.fn(); const mockSetReturnToStep = jest.fn(); const mockGoBack = jest.fn(); +const mockResetNavigation = jest.fn(); jest.mock("@/state", () => { const actual = jest.requireActual("@/state"); @@ -29,8 +45,20 @@ jest.mock("@/state", () => { goBack: mockGoBack, canGoBack: () => true, clearHistory: jest.fn(), + resetNavigation: mockResetNavigation, setReturnToStep: mockSetReturnToStep, }), + usePostcodeLookup: () => ({ + postcode: "", + addresses: [], + selectedAddress: null, + isLoading: false, + lookupResultsStatus: "idle", + error: null, + lookupPostcode: jest.fn(), + setSelectedAddress: jest.fn(), + clearAddresses: mockClearAddresses, + }), }; }); @@ -79,10 +107,10 @@ function StateSeeder({ function AuthSeeder({ children, user = defaultAuthUser, -}: { +}: Readonly<{ children: React.ReactNode; user?: AuthUser; -}) { +}>) { const { setUser } = useAuth(); useEffect(() => { @@ -139,6 +167,7 @@ describe("CheckYourAnswersPage", () => { beforeEach(() => { jest.clearAllMocks(); + mockNavigationType = "PUSH"; }); describe("Component Rendering", () => { @@ -308,6 +337,36 @@ describe("CheckYourAnswersPage", () => { }); describe("Submit Order", () => { + it("clears state and redirects to start when revisited via browser back after submission", async () => { + mockNavigationType = "POP"; + + render( + <> + + + , + { + wrapper: (props) => ( + + ), + }, + ); + + await waitFor(() => { + expect(mockClearAddresses).toHaveBeenCalled(); + expect(mockResetNavigation).toHaveBeenCalledWith(RoutePath.GetSelfTestKitPage, { + replace: true, + }); + expect(screen.getByTestId("order-reference")).toHaveTextContent(""); + }); + }); + it("shows error when submitting without consent", async () => { render(, { wrapper: TestWrapper }); diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx index 31e0807f..c6fde76a 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx @@ -1,13 +1,14 @@ -import { CreateOrderProvider, JourneyNavigationProvider, useCreateOrderContext } from "@/state"; import { render, screen } from "@testing-library/react"; - +import { useEffect } from "react"; import { MemoryRouter } from "react-router-dom"; + import OrderSubmittedPage from "@/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage"; -import { useEffect } from "react"; +import { CreateOrderProvider, JourneyNavigationProvider, useCreateOrderContext } from "@/state"; const mockGoToStep = jest.fn(); const mockSetReturnToStep = jest.fn(); const mockGoBack = jest.fn(); +const mockResetNavigation = jest.fn(); jest.mock("@/state", () => { const actual = jest.requireActual("@/state"); @@ -21,6 +22,7 @@ jest.mock("@/state", () => { goBack: mockGoBack, canGoBack: () => false, clearHistory: jest.fn(), + resetNavigation: mockResetNavigation, setReturnToStep: mockSetReturnToStep, }), }; diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx index 7466ba75..5faceb6a 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx @@ -1,4 +1,11 @@ import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useEffect } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { JourneyStepNames } from "@/lib/models/route-paths"; +import laLookupService from "@/lib/services/la-lookup-service"; +import SelectDeliveryAddressPage from "@/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage"; import { AuthContext, AuthUser, @@ -7,12 +14,6 @@ import { PostcodeLookupProvider, useCreateOrderContext, } from "@/state"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { JourneyStepNames } from "@/lib/models/route-paths"; -import { MemoryRouter } from "react-router-dom"; -import SelectDeliveryAddressPage from "@/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage"; -import laLookupService from "@/lib/services/la-lookup-service"; -import { useEffect } from "react"; const FIXED_TODAY = new Date(2026, 2, 4); // March 4, 2026 @@ -26,6 +27,7 @@ const mockNavigationContext: { goBack: jest.Mock; canGoBack: jest.Mock; clearHistory: jest.Mock; + resetNavigation: jest.Mock; stepHistory: string[]; returnToStep: string | null; setReturnToStep: jest.Mock; @@ -35,6 +37,7 @@ const mockNavigationContext: { goBack: jest.fn(), canGoBack: jest.fn(() => true), clearHistory: jest.fn(), + resetNavigation: jest.fn(), stepHistory: ["enter-delivery-address", "select-delivery-address"], returnToStep: null, setReturnToStep: jest.fn(), diff --git a/ui/src/__tests__/state/NavigationContext.test.tsx b/ui/src/__tests__/state/NavigationContext.test.tsx new file mode 100644 index 00000000..b22cbf5b --- /dev/null +++ b/ui/src/__tests__/state/NavigationContext.test.tsx @@ -0,0 +1,91 @@ +import "@testing-library/jest-dom"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; + +import { JourneyStepNames, RoutePath } from "@/lib/models/route-paths"; +import { SESSION_STORAGE_KEYS } from "@/lib/services/session-service"; +import { JourneyNavigationProvider, useJourneyNavigationContext } from "@/state/NavigationContext"; + +function TestWrapper({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} + +describe("NavigationContext", () => { + beforeEach(() => { + globalThis.sessionStorage.clear(); + }); + + describe("JourneyNavigationProvider", () => { + it("provides the current step as the initial history", () => { + const { result } = renderHook(() => useJourneyNavigationContext(), { + wrapper: TestWrapper, + }); + + expect(result.current.currentStep).toBe(JourneyStepNames.CheckYourAnswers); + expect(result.current.stepHistory).toEqual([JourneyStepNames.CheckYourAnswers]); + expect(result.current.returnToStep).toBeNull(); + }); + + it("rehydrates persisted navigation and appends the current step when needed", () => { + globalThis.sessionStorage.setItem( + SESSION_STORAGE_KEYS.journeyNavigation, + JSON.stringify({ + stepHistory: [JourneyStepNames.EnterMobileNumber], + returnToStep: JourneyStepNames.CheckYourAnswers, + }), + ); + + const { result } = renderHook(() => useJourneyNavigationContext(), { + wrapper: TestWrapper, + }); + + expect(result.current.stepHistory).toEqual([ + JourneyStepNames.EnterMobileNumber, + JourneyStepNames.CheckYourAnswers, + ]); + expect(result.current.returnToStep).toBe(JourneyStepNames.CheckYourAnswers); + }); + + it("resetNavigation clears in-memory navigation and storage for a redirect target", async () => { + const { result } = renderHook(() => useJourneyNavigationContext(), { + wrapper: TestWrapper, + }); + + act(() => { + result.current.setReturnToStep(JourneyStepNames.CheckYourAnswers); + }); + + await waitFor(() => { + expect( + globalThis.sessionStorage.getItem(SESSION_STORAGE_KEYS.journeyNavigation), + ).not.toBeNull(); + }); + + act(() => { + result.current.resetNavigation(RoutePath.GetSelfTestKitPage); + }); + + await waitFor(() => { + expect(result.current.currentStep).toBe(RoutePath.GetSelfTestKitPage); + }); + + expect(result.current.returnToStep).toBeNull(); + expect(result.current.stepHistory).toEqual([RoutePath.GetSelfTestKitPage]); + expect(globalThis.sessionStorage.getItem(SESSION_STORAGE_KEYS.journeyNavigation)).toBeNull(); + }); + }); + + describe("useJourneyNavigationContext", () => { + it("throws when used outside provider", () => { + expect(() => { + renderHook(() => useJourneyNavigationContext()); + }).toThrow("useJourneyNavigationContext must be used within a JourneyNavigationProvider"); + }); + }); +}); diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 957c65b0..ffe9e644 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -1,12 +1,11 @@ import * as React from "react"; -import { RouterProvider, createBrowserRouter } from "react-router-dom"; +import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"; import ErrorRedirect from "./components/ErrorRedirect"; import JourneyLayout from "./layouts/JourneyLayout"; import MainLayout from "./layouts/MainLayout"; import { JourneyStepNames, RoutePath } from "./lib/models/route-paths"; import CallbackPage from "./routes/CallbackPage"; -import HomePage from "./routes/HomePage"; import HomeTestPrivacyPolicyPage from "./routes/HomeTestPrivacyPolicyPage"; import HomeTestTermsOfUsePage from "./routes/HomeTestTermsOfUsePage"; import LoginPage from "./routes/LoginPage"; @@ -75,7 +74,7 @@ const router = createBrowserRouter([ children: [ { path: RoutePath.HomePage, - element: , + element: , }, { path: RoutePath.OrderTrackingPage, diff --git a/ui/src/routes/HomePage.tsx b/ui/src/routes/HomePage.tsx deleted file mode 100644 index 37e4eb8f..00000000 --- a/ui/src/routes/HomePage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { RoutePath } from "@/lib/models/route-paths"; -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; - -export default function HomePage() { - const navigate = useNavigate(); - - useEffect(() => { - navigate(RoutePath.GetSelfTestKitPage); - }, [navigate]); - - return
; -} diff --git a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx index 71762a9b..62a5a7ac 100644 --- a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx +++ b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx @@ -1,16 +1,21 @@ "use client"; import { Button, Checkboxes, ErrorSummary, SummaryList } from "nhsuk-react-components"; -import React, { useState } from "react"; +import React, { useLayoutEffect, useState } from "react"; +import { useNavigationType } from "react-router-dom"; import { useAsyncErrorHandler, useContent } from "@/hooks"; import FormPageLayout from "@/layouts/FormPageLayout"; -import { JourneyStepNames } from "@/lib/models/route-paths"; +import { JourneyStepNames, RoutePath } from "@/lib/models/route-paths"; import orderService, { OrderServiceRequest } from "@/lib/services/order-service"; -import { useAuth, useCreateOrderContext, useJourneyNavigationContext } from "@/state"; +import { + useAuth, + useCreateOrderContext, + useJourneyNavigationContext, + usePostcodeLookup, +} from "@/state"; // TODO: update to dynamically render supplier based on API (probably stored in state) -// TODO: add order reference number to state when order is submitted (orderAnswers.orderReferenceNumber) function formatAddress(address: { addressLine1?: string; @@ -39,10 +44,14 @@ function formatUserName(user?: { givenName: string; familyName: string } | null) } export default function CheckYourAnswersPage() { - const { orderAnswers, updateOrderAnswers } = useCreateOrderContext(); - const { goToStep, goBack, stepHistory, setReturnToStep } = useJourneyNavigationContext(); + const navigationType = useNavigationType(); + const { orderAnswers, updateOrderAnswers, reset } = useCreateOrderContext(); + const { goToStep, goBack, stepHistory, resetNavigation, setReturnToStep } = + useJourneyNavigationContext(); + const { clearAddresses } = usePostcodeLookup(); const { user } = useAuth(); const { commonContent, "check-your-answers": content } = useContent(); + const hasSubmittedOrder = orderAnswers.orderReferenceNumber != null; const [consentChecked, setConsentChecked] = useState( orderAnswers.consentCheckboxChecked ?? false, @@ -51,6 +60,16 @@ export default function CheckYourAnswersPage() { const supplierName = orderAnswers.supplier?.[0]?.name || "[Supplier]"; + useLayoutEffect(() => { + if (!hasSubmittedOrder || navigationType !== "POP") { + return; + } + + reset(); + clearAddresses(); + resetNavigation(RoutePath.GetSelfTestKitPage, { replace: true }); + }, [clearAddresses, hasSubmittedOrder, navigationType, reset, resetNavigation]); + const handleChangeClick = (field: "address" | "mobile" | "comfort") => { setReturnToStep(JourneyStepNames.CheckYourAnswers); @@ -140,6 +159,10 @@ export default function CheckYourAnswersPage() { ? formatAddress(orderAnswers.deliveryAddress) : []; + if (hasSubmittedOrder && navigationType === "POP") { + return null; + } + return ( void; canGoBack: () => boolean; clearHistory: () => void; + resetNavigation: (step?: Step, options?: { replace?: boolean }) => void; setReturnToStep: (step: Step | null) => void; } @@ -134,6 +135,22 @@ export function JourneyNavigationProvider({ children }: Readonly<{ children: Rea })); }, [currentStep]); + const resetNavigation = useCallback( + (step: Step = currentStep, options?: { replace?: boolean }) => { + setNavigation({ + stepHistory: [step], + returnToStep: null, + }); + + sessionService.clearJourneyNavigation(); + + if (step !== currentStep || options?.replace === true) { + navigate(getPathForStep(step), { replace: options?.replace }); + } + }, + [currentStep, navigate], + ); + const setReturnToStep = useCallback((step: Step | null) => { setNavigation((previousNavigation) => ({ ...previousNavigation, @@ -150,6 +167,7 @@ export function JourneyNavigationProvider({ children }: Readonly<{ children: Rea goBack, canGoBack, clearHistory, + resetNavigation, setReturnToStep, }), [ @@ -160,6 +178,7 @@ export function JourneyNavigationProvider({ children }: Readonly<{ children: Rea goBack, canGoBack, clearHistory, + resetNavigation, setReturnToStep, ], );