diff --git a/packages/cpt-ui/__tests__/BasicDetailsSearch.test.tsx b/packages/cpt-ui/__tests__/BasicDetailsSearch.test.tsx index beee73db60..cf53f88049 100644 --- a/packages/cpt-ui/__tests__/BasicDetailsSearch.test.tsx +++ b/packages/cpt-ui/__tests__/BasicDetailsSearch.test.tsx @@ -21,6 +21,7 @@ import {STRINGS} from "@/constants/ui-strings/BasicDetailsSearchStrings" import {FRONTEND_PATHS} from "@/constants/environment" import {AuthContext, AuthContextType} from "@/context/AuthProvider" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" jest.mock("react-router-dom", () => { const actual = jest.requireActual("react-router-dom") @@ -68,10 +69,12 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() +const mockSetSearchType = jest.fn() + const defaultSearchState: SearchProviderContextType = { prescriptionId: undefined, issueNumber: undefined, @@ -82,6 +85,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -93,7 +97,8 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, getAllSearchParameters: mockGetAllSearchParameters, - setAllSearchParameters: mockSetAllSearchParameters + setAllSearchParameters: mockSetAllSearchParameters, + setSearchType: mockSetSearchType } const LocationDisplay = () => { @@ -106,10 +111,12 @@ const renderWithRouter = (ui: React.ReactElement, searchState: SearchProviderCon - - - } /> - + + + + } /> + + diff --git a/packages/cpt-ui/__tests__/BasicDetailsSearchResultsPage.test.tsx b/packages/cpt-ui/__tests__/BasicDetailsSearchResultsPage.test.tsx index 56c881a732..5f584be7dd 100644 --- a/packages/cpt-ui/__tests__/BasicDetailsSearchResultsPage.test.tsx +++ b/packages/cpt-ui/__tests__/BasicDetailsSearchResultsPage.test.tsx @@ -11,12 +11,37 @@ import {SearchResultsPageStrings} from "@/constants/ui-strings/BasicDetailsSearc import http from "@/helpers/axios" import {AuthContext, type AuthContextType} from "@/context/AuthProvider" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" import {AxiosError, AxiosHeaders} from "axios" // Mock the axios module jest.mock("@/helpers/axios") const mockAxiosGet = http.get as jest.MockedFunction +const mockGetRelevantSearchParameters = jest.fn() +const mockGetBackPath = jest.fn() +const mockGoBack = jest.fn() +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: mockGoBack, + getBackPath: mockGetBackPath, + clearNavigation: jest.fn(), + getCurrentEntry: jest.fn(), + getNavigationStack: jest.fn(), + canGoBack: jest.fn(), + setOriginalSearchPage: jest.fn(), + getOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: mockGetRelevantSearchParameters, + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) + const mockAuthContext: AuthContextType = { error: null, user: null, @@ -49,7 +74,7 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() @@ -63,6 +88,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -73,6 +99,7 @@ const defaultSearchState: SearchProviderContextType = { setDobYear: mockSetDobYear, setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, + setSearchType: jest.fn(), getAllSearchParameters: mockGetAllSearchParameters, setAllSearchParameters: mockSetAllSearchParameters } @@ -96,17 +123,20 @@ const mockPatients = [ } ] -function renderWithRouter() { +function renderWithRouter(initialEntries = ["/patient-search-results"]) { return render( - - - } /> - } /> - } /> - } /> - + + + + } /> + } /> + } /> + } /> + } /> + + @@ -115,11 +145,24 @@ function renderWithRouter() { describe("BasicDetailsSearchResultsPage", () => { beforeEach(() => { + jest.clearAllMocks() + // Mock successful API response mockAxiosGet.mockResolvedValue({ status: 200, data: mockPatients }) + + mockGetRelevantSearchParameters.mockReturnValue({ + firstName: "John", + lastName: "Doe", + dobDay: "01", + dobMonth: "01", + dobYear: "1990", + postcode: "SW1A 1AA" + }) + + mockGetBackPath.mockReturnValue("/search-by-basic-details") }) it("shows loading state initially", () => { @@ -201,20 +244,42 @@ describe("BasicDetailsSearchResultsPage", () => { await waitFor(() => { expect(screen.getByTestId("prescription-list-shown")).toBeInTheDocument() - expect(mockClearSearchParameters).toHaveBeenCalled() - expect(mockSetNhsNumber).toHaveBeenCalledWith("9726919207") + expect(mockGetRelevantSearchParameters).toHaveBeenCalledWith( + "basicDetails" + ) + expect(mockSetAllSearchParameters).toHaveBeenCalledWith({ + firstName: "John", + lastName: "Doe", + dobDay: "01", + dobMonth: "01", + dobYear: "1990", + postcode: "SW1A 1AA", + nhsNumber: "9726919207" + }) }) }) it("navigates to prescription list when clicking a patient row", async () => { renderWithRouter() await waitFor(() => { - const firstPatientRow = screen.getByText("Issac Wolderton-Rodriguez").closest("tr") + const firstPatientRow = screen + .getByText("Issac Wolderton-Rodriguez") + .closest("tr") fireEvent.click(firstPatientRow!) expect(screen.getByTestId("prescription-list-shown")).toBeInTheDocument() - expect(mockClearSearchParameters).toHaveBeenCalled() - expect(mockSetNhsNumber).toHaveBeenCalledWith("9726919207") + expect(mockGetRelevantSearchParameters).toHaveBeenCalledWith( + "basicDetails" + ) + expect(mockSetAllSearchParameters).toHaveBeenCalledWith({ + firstName: "John", + lastName: "Doe", + dobDay: "01", + dobMonth: "01", + dobYear: "1990", + postcode: "SW1A 1AA", + nhsNumber: "9726919207" + }) }) }) @@ -226,8 +291,18 @@ describe("BasicDetailsSearchResultsPage", () => { fireEvent.click(patientNameLink) expect(screen.getByTestId("prescription-list-shown")).toBeInTheDocument() - expect(mockClearSearchParameters).toHaveBeenCalled() - expect(mockSetNhsNumber).toHaveBeenCalledWith("9726919207") + expect(mockGetRelevantSearchParameters).toHaveBeenCalledWith( + "basicDetails" + ) + expect(mockSetAllSearchParameters).toHaveBeenCalledWith({ + firstName: "John", + lastName: "Doe", + dobDay: "01", + dobMonth: "01", + dobYear: "1990", + postcode: "SW1A 1AA", + nhsNumber: "9726919207" + }) }) }) @@ -235,11 +310,16 @@ describe("BasicDetailsSearchResultsPage", () => { renderWithRouter() await waitFor(() => { - const backLink = screen.getByText(SearchResultsPageStrings.GO_BACK) - fireEvent.click(backLink) - - expect(screen.getByTestId("search-page-shown")).toBeInTheDocument() + expect( + screen.getByText(SearchResultsPageStrings.GO_BACK) + ).toBeInTheDocument() }) + + const backLink = screen.getByText(SearchResultsPageStrings.GO_BACK) + fireEvent.click(backLink) + + expect(mockGoBack).toHaveBeenCalled() + expect(mockGetBackPath).toHaveBeenCalled() }) it("handles enter key navigation for patient rows", async () => { @@ -250,8 +330,18 @@ describe("BasicDetailsSearchResultsPage", () => { fireEvent.keyDown(firstPatientRow!, {key: "Enter"}) expect(screen.getByTestId("prescription-list-shown")).toBeInTheDocument() - expect(mockClearSearchParameters).toHaveBeenCalled() - expect(mockSetNhsNumber).toHaveBeenCalledWith("9726919207") + expect(mockGetRelevantSearchParameters).toHaveBeenCalledWith( + "basicDetails" + ) + expect(mockSetAllSearchParameters).toHaveBeenCalledWith({ + firstName: "John", + lastName: "Doe", + dobDay: "01", + dobMonth: "01", + dobYear: "1990", + postcode: "SW1A 1AA", + nhsNumber: "9726919207" + }) }) }) @@ -263,8 +353,18 @@ describe("BasicDetailsSearchResultsPage", () => { fireEvent.keyDown(firstPatientRow!, {key: " "}) expect(screen.getByTestId("prescription-list-shown")).toBeInTheDocument() - expect(mockClearSearchParameters).toHaveBeenCalled() - expect(mockSetNhsNumber).toHaveBeenCalledWith("9726919207") + expect(mockGetRelevantSearchParameters).toHaveBeenCalledWith( + "basicDetails" + ) + expect(mockSetAllSearchParameters).toHaveBeenCalledWith({ + firstName: "John", + lastName: "Doe", + dobDay: "01", + dobMonth: "01", + dobYear: "1990", + postcode: "SW1A 1AA", + nhsNumber: "9726919207" + }) }) }) diff --git a/packages/cpt-ui/__tests__/EpsPrescriptionList.test.tsx b/packages/cpt-ui/__tests__/EpsPrescriptionList.test.tsx index 2b87806995..f37ccd4f09 100644 --- a/packages/cpt-ui/__tests__/EpsPrescriptionList.test.tsx +++ b/packages/cpt-ui/__tests__/EpsPrescriptionList.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import React from "react" import {MemoryRouter, Route, Routes} from "react-router-dom" @@ -18,12 +19,112 @@ import axios from "@/helpers/axios" import {logger} from "@/helpers/logger" jest.mock("@/helpers/axios") +jest.mock("@/constants/environment", () => ({ + AUTH_CONFIG: { + USER_POOL_ID: "test-user-pool-id", + USER_POOL_CLIENT_ID: "test-client-id", + HOSTED_LOGIN_DOMAIN: "test-domain", + REDIRECT_SIGN_IN: "http://localhost/", + REDIRECT_SIGN_OUT: "http://localhost/", + REDIRECT_SESSION_SIGN_OUT: "http://localhost/" + }, + ENV_CONFIG: { + TARGET_ENVIRONMENT: "test", + API_DOMAIN_OVERRIDE: undefined, + BASE_PATH: "site", + LOCAL_DEV: false, + BASE_URL: "http://localhost", + BASE_URL_PATH: "http://localhost/site/" + }, + APP_CONFIG: { + SERVICE_NAME: "test-service", + COMMIT_ID: "test-commit", + VERSION_NUMBER: "1.0.0", + REACT_LOG_LEVEL: "debug" + }, + API_ENDPOINTS: { + TRACKER_USER_INFO: "/api/tracker-user-info", + SELECTED_ROLE: "/api/selected-role", + PRESCRIPTION_LIST: "/api/prescription-list", + CIS2_SIGNOUT_ENDPOINT: "/api/cis2-signout", + PRESCRIPTION_DETAILS: "/api/prescription-details", + PATIENT_SEARCH: "/api/patient-search", + SESSION_MANAGEMENT: "/api/session-management" + }, + RUM_CONFIG: { + GUEST_ROLE_ARN: "test-guest-role-arn", + IDENTITY_POOL_ID: "test-identity-pool", + ENDPOINT: "https://dataplane.rum.eu-west-2.amazonaws.com", + APPLICATION_ID: "test-app-id", + REGION: "eu-west-2", + VERSION: "1.0.0", + ALLOW_COOKIES: true, + ENABLE_XRAY: false, + SESSION_SAMPLE_RATE: 1, + TELEMETRIES: [], + RELEASE_ID: "test-commit" + }, + FRONTEND_PATHS: { + PRESCRIPTION_LIST_CURRENT: "/prescription-list-current", + PRESCRIPTION_LIST_FUTURE: "/prescription-list-future", + PRESCRIPTION_LIST_PAST: "/prescription-list-past", + COOKIES: "/cookies", + LOGIN: "/login", + LOGOUT: "/logout", + SESSION_LOGGED_OUT: "/session-logged-out", + SELECT_YOUR_ROLE: "/select-your-role", + YOUR_SELECTED_ROLE: "/your-selected-role", + CHANGE_YOUR_ROLE: "/change-your-role", + SEARCH_BY_PRESCRIPTION_ID: "/search-by-prescription-id", + SEARCH_BY_NHS_NUMBER: "/search-by-nhs-number", + SEARCH_BY_BASIC_DETAILS: "/search-by-basic-details", + PRESCRIPTION_DETAILS_PAGE: "/prescription-details", + PATIENT_SEARCH_RESULTS: "/patient-search-results", + PATIENT_NOT_FOUND: "/patient-not-found", + PRIVACY_NOTICE: "/privacy-notice", + COOKIES_SELECTED: "/cookies-selected", + SESSION_SELECTION: "/select-active-session", + NOT_FOUND: "/notfound" + }, + PUBLIC_PATHS: ["/login", "/cookies", "/privacy-notice"] +})) + // Tell TypeScript that axios is a mocked version. const mockedAxios = axios as jest.Mocked import PrescriptionListPage from "@/pages/PrescriptionListPage" import {AuthContextType, AuthContext} from "@/context/AuthProvider" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" + +const mockGetBackPath = jest.fn() +const mockSetOriginalSearchPage = jest.fn() +const mockCaptureOriginalSearchParameters = jest.fn() +const mockGetRelevantSearchParameters = jest.fn() +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: jest.fn(), + getBackPath: mockGetBackPath, + setOriginalSearchPage: mockSetOriginalSearchPage, + captureOriginalSearchParameters: mockCaptureOriginalSearchParameters, + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: mockGetRelevantSearchParameters, + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) + +const mockGetBackLink = jest.fn() +const mockGoBack = jest.fn() +jest.mock("@/hooks/useBackNavigation", () => ({ + useBackNavigation: () => ({ + getBackLink: mockGetBackLink, + goBack: mockGoBack + }) +})) const mockCognitoSignIn = jest.fn() const mockCognitoSignOut = jest.fn() @@ -60,10 +161,11 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() +const mockSetSearchType = jest.fn() const defaultSearchState: SearchProviderContextType = { prescriptionId: undefined, issueNumber: undefined, @@ -74,6 +176,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -85,7 +188,8 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, getAllSearchParameters: mockGetAllSearchParameters, - setAllSearchParameters: mockSetAllSearchParameters + setAllSearchParameters: mockSetAllSearchParameters, + setSearchType: mockSetSearchType } const mockSearchResponse: SearchResponse = { @@ -208,13 +312,15 @@ const renderWithRouter = ( - - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + + @@ -249,6 +355,17 @@ export function createAxiosError(status: number): AxiosError { describe("PrescriptionListPage", () => { beforeEach(() => { jest.restoreAllMocks() + jest.clearAllMocks() + + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) + mockGetRelevantSearchParameters.mockReturnValue({}) + + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) + }) + + afterEach(() => { + // cleans up DOM + document.body.innerHTML = "" }) it("renders the loading spinner before the request resolves", () => { @@ -415,9 +532,7 @@ describe("PrescriptionListPage", () => { status: 200, data: mockSearchResponse }) - mockGetAllSearchParameters.mockReturnValue({ - prescriptionId: "ABC123-A83008-C2D93O" - }) + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) renderWithRouter( FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT, @@ -443,6 +558,8 @@ describe("PrescriptionListPage", () => { status: 200, data: mockSearchResponse }) + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) + renderWithRouter( FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT, signedInAuthState, @@ -451,38 +568,105 @@ describe("PrescriptionListPage", () => { nhsNumber: "1234567890" } ) + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + await waitFor(() => { + const backLink = screen.getByTestId("go-back-link") + expect(backLink).toHaveAttribute( + "href", + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + ) + }) + }) + + it("sets back link to prescription ID search when both prescriptionId and nhsNumber are present on prescription list page", async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockSearchResponse + }) + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) + + const url = FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT + renderWithRouter(url, signedInAuthState, { + ...defaultSearchState, + prescriptionId: "ABC123-A83008-C2D93O", + nhsNumber: "1234567890" + }) expect(mockedAxios.get).toHaveBeenCalledTimes(1) await waitFor(() => { const backLink = screen.getByTestId("go-back-link") - expect(backLink).toHaveAttribute("href", FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) + expect(backLink).toHaveAttribute( + "href", + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + ) }) }) - it("sets back link to prescription list when both prescriptionId and nhsNumber are present", async () => { + it("uses nhsNumber for API call when both prescriptionId and nhsNumber are present", async () => { mockedAxios.get.mockResolvedValue({ status: 200, data: mockSearchResponse }) const url = FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT + renderWithRouter(url, signedInAuthState, { + ...defaultSearchState, + prescriptionId: "ABC123-A83008-C2D93O", + nhsNumber: "1234567890" + }) + + await waitFor(() => { + expect(mockedAxios.get).toHaveBeenCalledWith("/api/prescription-list", { + params: new URLSearchParams([["nhsNumber", "1234567890"]]) + }) + }) + }) + + it("sets back link to prescription ID search when navigating to future prescriptions tab", async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockSearchResponse + }) + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) + renderWithRouter( - url, + FRONTEND_PATHS.PRESCRIPTION_LIST_FUTURE, signedInAuthState, { ...defaultSearchState, - nhsNumber: "1234567890", prescriptionId: "ABC123-A83008-C2D93O" } - ) - expect(mockedAxios.get).toHaveBeenCalledTimes(1) await waitFor(() => { const backLink = screen.getByTestId("go-back-link") - expect(backLink).toHaveAttribute("href", FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT) + expect(backLink).toHaveAttribute( + "href", + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + ) + }) + }) + + it("sets back link to NHS number search when navigating to past prescriptions tab", async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockSearchResponse + }) + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) + + renderWithRouter(FRONTEND_PATHS.PRESCRIPTION_LIST_PAST, signedInAuthState, { + ...defaultSearchState, + nhsNumber: "1234567890" + }) + + await waitFor(() => { + const backLink = screen.getByTestId("go-back-link") + expect(backLink).toHaveAttribute( + "href", + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + ) }) }) @@ -543,6 +727,7 @@ describe("PrescriptionListPage", () => { it("renders prescription not found message with back link to NHS number search when query fails", async () => { mockedAxios.get.mockRejectedValue(createAxiosError(404)) + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) renderWithRouter( FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT, @@ -569,6 +754,7 @@ describe("PrescriptionListPage", () => { it("renders prescription not found message when API returns no prescriptions for a valid NHS number", async () => { mockedAxios.get.mockResolvedValue(emptyResultsMock) + mockGetBackLink.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) renderWithRouter( FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT, @@ -578,7 +764,6 @@ describe("PrescriptionListPage", () => { nhsNumber: "32165649870" } ) - expect(mockedAxios.get).toHaveBeenCalledTimes(1) await waitFor(() => { const heading = screen.getByTestId("presc-not-found-heading") @@ -609,7 +794,9 @@ describe("PrescriptionListPage", () => { ) await waitFor(() => { - expect(screen.getByTestId("presc-not-found-heading")).toHaveTextContent(STRINGS.heading) + expect(screen.getByTestId("presc-not-found-heading")).toHaveTextContent( + STRINGS.heading + ) }) }) diff --git a/packages/cpt-ui/__tests__/NavigationProvider.test.tsx b/packages/cpt-ui/__tests__/NavigationProvider.test.tsx new file mode 100644 index 0000000000..348818ebe0 --- /dev/null +++ b/packages/cpt-ui/__tests__/NavigationProvider.test.tsx @@ -0,0 +1,420 @@ +import React from "react" +import {render, act, renderHook} from "@testing-library/react" +import {MemoryRouter} from "react-router-dom" +import {NavigationContextType, NavigationProvider, useNavigationContext} from "@/context/NavigationProvider" +import {FRONTEND_PATHS} from "@/constants/environment" +import {logger} from "@/helpers/logger" + +jest.mock("@/helpers/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn() + } +})) + +const mockNavigate = jest.fn() +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => mockNavigate +})) + +const TestWrapper: React.FC<{ + children: React.ReactNode; + initialEntries?: Array; +}> = ({children, initialEntries = ["/"]}) => ( + + {children} + +) + +const TestComponent: React.FC<{ + onContextReady?: (context: NavigationContextType) => void; +}> = ({onContextReady}) => { + const navigationContext = useNavigationContext() + + React.useEffect(() => { + if (onContextReady) { + onContextReady(navigationContext) + } + }, [navigationContext, onContextReady]) + + return
Test Component
+} + +describe("NavigationProvider", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("useNavigationContext hook", () => { + it("should provide context when used within NavigationProvider", () => { + const {result} = renderHook(() => useNavigationContext(), { + wrapper: TestWrapper + }) + + expect(result.current).toBeDefined() + expect(typeof result.current.pushNavigation).toBe("function") + expect(typeof result.current.goBack).toBe("function") + expect(typeof result.current.getBackPath).toBe("function") + }) + }) + + describe("pushNavigation", () => { + it("should add new entry and handle duplicates", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.pushNavigation("/test-path") + context!.pushNavigation("/test-path") // duplicate should be ignored + }) + + expect(logger.info).toHaveBeenCalledWith("Navigation: Pushing entry", { + path: "/test-path", + stackLength: expect.any(Number) + }) + }) + + it("should handle prescription details and list pages specially", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.pushNavigation(FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT) + context!.pushNavigation(FRONTEND_PATHS.PRESCRIPTION_DETAILS_PAGE) + }) + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("Navigation:"), + expect.any(Object) + ) + }) + }) + + describe("setOriginalSearchPage", () => { + it.each([ + ["prescriptionId", FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID], + ["nhsNumber", FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER], + ["basicDetails", FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS] + ])( + "should set original search page for %s search", + async (searchType, expectedPath) => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.setOriginalSearchPage( + searchType as "prescriptionId" | "nhsNumber" | "basicDetails" + ) + }) + + expect(logger.info).toHaveBeenCalledWith( + "Navigation: Setting original search page", + {path: expectedPath} + ) + } + ) + }) + + describe("captureOriginalSearchParameters", () => { + it("should capture parameters and clear navigation stack", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + const searchParams = {prescriptionId: "12345", issueNumber: "1"} + + await act(async () => { + context!.captureOriginalSearchParameters( + "prescriptionId", + searchParams + ) + }) + + expect(logger.info).toHaveBeenCalledWith( + "Navigation: Capturing original search parameters", + {searchType: "prescriptionId", params: searchParams} + ) + }) + + it("should handle basic details search parameters", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + const searchParams = { + firstName: "John", + lastName: "Doe", + dobDay: "01", + dobMonth: "01", + dobYear: "1990", + postcode: "SW1A 1AA" + } + + await act(async () => { + context!.captureOriginalSearchParameters("basicDetails", searchParams) + }) + + expect(logger.info).toHaveBeenCalledWith( + "Navigation: Capturing original search parameters", + {searchType: "basicDetails", params: searchParams} + ) + }) + }) + + describe("getOriginalSearchParameters", () => { + it("should return null when no parameters captured", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + expect(context!.getOriginalSearchParameters()).toBeNull() + }) + + it("should return captured parameters", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + const searchParams = {prescriptionId: "12345"} + + await act(async () => { + context!.captureOriginalSearchParameters( + "prescriptionId", + searchParams + ) + }) + + expect(context!.getOriginalSearchParameters()).toEqual(searchParams) + }) + }) + + describe("getRelevantSearchParameters", () => { + it("should return empty object when no parameters or type mismatch", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.captureOriginalSearchParameters("prescriptionId", { + prescriptionId: "12345" + }) + }) + + expect(context!.getRelevantSearchParameters("prescriptionId")).toEqual({ + prescriptionId: "12345" + }) + expect(context!.getRelevantSearchParameters("nhsNumber")).toEqual({}) + }) + + it("should filter relevant parameters correctly", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + const allParams = { + prescriptionId: "12345", + issueNumber: "1", + firstName: "John", + nhsNumber: "1234567890", + postcode: "SW1A 1AA" + } + + await act(async () => { + context!.captureOriginalSearchParameters("basicDetails", allParams) + }) + + const result = context!.getRelevantSearchParameters("basicDetails") + expect(result).toEqual({ + firstName: "John", + postcode: "SW1A 1AA" + }) + + expect(logger.info).toHaveBeenCalledWith( + "Navigation: Getting relevant search parameters", + expect.objectContaining({searchType: "basicDetails"}) + ) + }) + + it("should handle undefined parameters gracefully", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.captureOriginalSearchParameters("prescriptionId", { + prescriptionId: "12345", + issueNumber: undefined + }) + }) + + expect(context!.getRelevantSearchParameters("prescriptionId")).toEqual({ + prescriptionId: "12345" + }) + }) + }) + + describe("getBackPath", () => { + it("should return null when no navigation history", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + expect(context!.getBackPath()).toBeNull() + }) + + it("should handle prescription details navigation", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.pushNavigation("/prescription-list-current") + context!.pushNavigation("/prescription-details") + }) + + expect(context!.getBackPath()).toBe("/prescription-list-current") + }) + + it("should return original search page when available", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.setOriginalSearchPage("basicDetails") + }) + + expect(context!.getBackPath()).toBe( + FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + ) + }) + }) + + describe("goBack", () => { + it("should warn when no back path available", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.goBack() + }) + + expect(logger.warn).toHaveBeenCalledWith( + "Navigation: No back path found, staying on current page" + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("should navigate and update stack", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.pushNavigation("/page1") + context!.pushNavigation("/page2") + }) + + const backPath = context!.getBackPath() + expect(backPath).not.toBeNull() + + await act(async () => { + context!.goBack() + }) + + expect(mockNavigate).toHaveBeenCalled() + }) + + it("should handle prescription list to search navigation", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.setOriginalSearchPage("basicDetails") + }) + + expect(context!.getBackPath()).toBe("/search-by-basic-details") + + await act(async () => { + context!.goBack() + }) + + expect(mockNavigate).toHaveBeenCalledWith("/search-by-basic-details") + }) + }) + + describe("startNewNavigationSession", () => { + it("should clear all navigation state", async () => { + let context: NavigationContextType | undefined + render( + + (context = ctx)} /> + + ) + + await act(async () => { + context!.captureOriginalSearchParameters("basicDetails", { + firstName: "John" + }) + context!.pushNavigation("/some-page") + context!.startNewNavigationSession() + }) + + expect(context!.getOriginalSearchParameters()).toBeNull() + }) + }) +}) diff --git a/packages/cpt-ui/__tests__/NhsNumSearch.test.tsx b/packages/cpt-ui/__tests__/NhsNumSearch.test.tsx index 65a66b34f3..402f64b44c 100644 --- a/packages/cpt-ui/__tests__/NhsNumSearch.test.tsx +++ b/packages/cpt-ui/__tests__/NhsNumSearch.test.tsx @@ -14,6 +14,23 @@ import NhsNumSearch from "@/components/prescriptionSearch/NhsNumSearch" import {STRINGS} from "@/constants/ui-strings/NhsNumSearchStrings" import {FRONTEND_PATHS} from "@/constants/environment" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" + +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: jest.fn(), + getBackPath: jest.fn(), + setOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: jest.fn(), + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) jest.mock("react-router-dom", () => { const actual = jest.requireActual("react-router-dom") @@ -32,10 +49,12 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() +const mockSetSearchType = jest.fn() + const defaultSearchState: SearchProviderContextType = { prescriptionId: undefined, issueNumber: undefined, @@ -46,6 +65,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -57,22 +77,29 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, getAllSearchParameters: mockGetAllSearchParameters, - setAllSearchParameters: mockSetAllSearchParameters + setAllSearchParameters: mockSetAllSearchParameters, + setSearchType: mockSetSearchType } const LocationDisplay = () => { const location = useLocation() - return
{location.pathname + location.search}
+ return ( +
+ {location.pathname + location.search} +
+ ) } const renderWithRouter = (ui: React.ReactElement) => { return render( - - - } /> - + + + + } /> + + ) diff --git a/packages/cpt-ui/__tests__/PatientNotFoundMessage.test.tsx b/packages/cpt-ui/__tests__/PatientNotFoundMessage.test.tsx index 5dd4aa7d4f..ad12856ba1 100644 --- a/packages/cpt-ui/__tests__/PatientNotFoundMessage.test.tsx +++ b/packages/cpt-ui/__tests__/PatientNotFoundMessage.test.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-len */ + import "@testing-library/jest-dom" import {render, screen, fireEvent} from "@testing-library/react" import {MemoryRouter, Routes, Route} from "react-router-dom" @@ -6,25 +8,57 @@ import React from "react" import PatientNotFoundMessage from "@/components/PatientNotFoundMessage" import {STRINGS} from "@/constants/ui-strings/PatientNotFoundMessageStrings" import {FRONTEND_PATHS} from "@/constants/environment" +import {NavigationProvider} from "@/context/NavigationProvider" + +const mockGetBackPath = jest.fn() +const mockGoBack = jest.fn() +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: mockGoBack, + getBackPath: mockGetBackPath, + clearNavigation: jest.fn(), + getCurrentEntry: jest.fn(), + getNavigationStack: jest.fn(), + canGoBack: jest.fn(), + setOriginalSearchPage: jest.fn(), + getOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: jest.fn(), + startNewNavigationSession: jest.fn() +} -const DummyPage = ({label}: {label: string}) =>
{label}
+jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) + +const DummyPage = ({label}: { label: string }) => ( +
{label}
+) function setupRouter( search = "?firstName=Zoe&lastName=Zero&dobDay=31&dobMonth=12&dobYear=2021&postcode=AB1%202CD" ) { render( - - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + + ) } describe("PatientNotFoundMessage", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + it("renders the main heading and static content", () => { setupRouter() expect(screen.getByTestId("patient-not-found-heading")).toHaveTextContent(STRINGS.heading) @@ -35,6 +69,10 @@ describe("PatientNotFoundMessage", () => { it("renders the go-back link with search query, and navigates to Basic Details Search", () => { const search = "?firstName=Zoe&lastName=Zero&dobDay=31&dobMonth=12&dobYear=2021&postcode=AB1%202CD" + mockGetBackPath.mockReturnValue( + FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + search + ) + setupRouter(search) const backLink = screen.getByTestId("go-back-link") expect(backLink).toHaveAttribute( @@ -42,10 +80,12 @@ describe("PatientNotFoundMessage", () => { FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + search ) fireEvent.click(backLink) - expect(screen.getByTestId("dummy-page")).toHaveTextContent("Basic Details Search") + expect(mockGoBack).toHaveBeenCalled() }) it("navigates to NHS Number Search when alternate link is clicked", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + setupRouter() const nhsNumberLink = screen.getByTestId("patient-not-found-nhs-number-link") expect(nhsNumberLink).toHaveAttribute("href", FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) @@ -54,6 +94,8 @@ describe("PatientNotFoundMessage", () => { }) it("navigates to Prescription ID Search when alternate link is clicked", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + setupRouter() const prescriptionIdLink = screen.getByTestId("patient-not-found-prescription-id-link") expect(prescriptionIdLink).toHaveAttribute("href", FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) @@ -62,10 +104,12 @@ describe("PatientNotFoundMessage", () => { }) it("renders correctly even with empty or no search parameter", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + setupRouter("") // Empty search const backLink = screen.getByTestId("go-back-link") expect(backLink).toHaveAttribute("href", FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) fireEvent.click(backLink) - expect(screen.getByTestId("dummy-page")).toHaveTextContent("Basic Details Search") + expect(mockGoBack).toHaveBeenCalled() }) }) diff --git a/packages/cpt-ui/__tests__/PrescriptionDetailsPage.test.tsx b/packages/cpt-ui/__tests__/PrescriptionDetailsPage.test.tsx index 7e2b1b572a..51143d6c10 100644 --- a/packages/cpt-ui/__tests__/PrescriptionDetailsPage.test.tsx +++ b/packages/cpt-ui/__tests__/PrescriptionDetailsPage.test.tsx @@ -15,6 +15,7 @@ import PrescriptionDetailsPage from "@/pages/PrescriptionDetailsPage" import http from "@/helpers/axios" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" import {AxiosError, AxiosHeaders} from "axios" @@ -58,6 +59,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: jest.fn().mockName("clearSearchParameters"), setPrescriptionId: jest.fn().mockName("setPrescriptionId"), setIssueNumber: jest.fn().mockName("setIssueNumber"), @@ -69,7 +71,8 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: jest.fn().mockName("setPostcode"), setNhsNumber: jest.fn().mockName("setNhsNumber"), getAllSearchParameters: jest.fn().mockName("getAllSearchParameters"), - setAllSearchParameters: jest.fn().mockName("setAllSearchParameters") + setAllSearchParameters: jest.fn().mockName("setAllSearchParameters"), + setSearchType: jest.fn().mockName("setSearchType") } // Mock the spinner component. @@ -103,11 +106,13 @@ const renderComponent = ( - - } /> - } /> - } /> - + + + } /> + } /> + } /> + + diff --git a/packages/cpt-ui/__tests__/PrescriptionIdSearch.test.tsx b/packages/cpt-ui/__tests__/PrescriptionIdSearch.test.tsx index 864d6bd5e5..b4f5efb905 100644 --- a/packages/cpt-ui/__tests__/PrescriptionIdSearch.test.tsx +++ b/packages/cpt-ui/__tests__/PrescriptionIdSearch.test.tsx @@ -14,6 +14,23 @@ import PrescriptionIdSearch from "@/components/prescriptionSearch/PrescriptionId import {PRESCRIPTION_ID_SEARCH_STRINGS} from "@/constants/ui-strings/SearchForAPrescriptionStrings" import {AuthContext, AuthContextType} from "@/context/AuthProvider" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" + +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: jest.fn(), + getBackPath: jest.fn(), + setOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: jest.fn(), + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) const mockAuthContext: AuthContextType = { error: null, @@ -47,10 +64,11 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() +const mockSetSearchType = jest.fn() const defaultSearchState: SearchProviderContextType = { prescriptionId: undefined, issueNumber: undefined, @@ -61,6 +79,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -72,7 +91,8 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, getAllSearchParameters: mockGetAllSearchParameters, - setAllSearchParameters: mockSetAllSearchParameters + setAllSearchParameters: mockSetAllSearchParameters, + setSearchType: mockSetSearchType } const LocationDisplay = () => { @@ -85,10 +105,12 @@ const renderWithRouter = () => - - } /> - } /> - + + + } /> + } /> + + @@ -104,7 +126,10 @@ const setup = async (input: string) => { } describe("PrescriptionIdSearch", () => { - beforeEach(() => jest.resetAllMocks()) + beforeEach(() => { + jest.resetAllMocks() + jest.clearAllMocks() + }) it("renders label, hint, and button", () => { renderWithRouter() diff --git a/packages/cpt-ui/__tests__/PrescriptionListTable.test.tsx b/packages/cpt-ui/__tests__/PrescriptionListTable.test.tsx index 191b0c71b8..baa4dd7fa1 100644 --- a/packages/cpt-ui/__tests__/PrescriptionListTable.test.tsx +++ b/packages/cpt-ui/__tests__/PrescriptionListTable.test.tsx @@ -10,6 +10,7 @@ import {PrescriptionsListStrings} from "@/constants/ui-strings/PrescriptionListT import {PrescriptionSummary, TreatmentType} from "@cpt-ui-common/common-types" import {MemoryRouter} from "react-router-dom" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" jest.mock("@/helpers/statusMetadata", () => ({ getStatusTagColour: jest.fn().mockReturnValue("blue"), @@ -17,6 +18,31 @@ jest.mock("@/helpers/statusMetadata", () => ({ formatDateForPrescriptions: jest.fn((date: string) => "Formatted: " + date) })) +// Mock the navigation context +const mockGetRelevantSearchParameters = jest.fn() +const mockGetOriginalSearchParameters = jest.fn() +const mockGoBack = jest.fn() +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: mockGoBack, + getBackPath: jest.fn(), + clearNavigation: jest.fn(), + getCurrentEntry: jest.fn(), + getNavigationStack: jest.fn(), + canGoBack: jest.fn(), + setOriginalSearchPage: jest.fn(), + getOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: mockGetOriginalSearchParameters, + getRelevantSearchParameters: mockGetRelevantSearchParameters, + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) + const mockClearSearchParameters = jest.fn() const mockSetPrescriptionId = jest.fn() const mockSetIssueNumber = jest.fn() @@ -25,10 +51,12 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() +const mockSetSearchType = jest.fn() + const defaultSearchState: SearchProviderContextType = { prescriptionId: undefined, issueNumber: undefined, @@ -39,6 +67,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -50,7 +79,8 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, getAllSearchParameters: mockGetAllSearchParameters, - setAllSearchParameters: mockSetAllSearchParameters + setAllSearchParameters: mockSetAllSearchParameters, + setSearchType: mockSetSearchType } describe("PrescriptionsListTable", () => { @@ -102,7 +132,7 @@ describe("PrescriptionsListTable", () => { return render( - {component} + {component} ) @@ -110,6 +140,10 @@ describe("PrescriptionsListTable", () => { beforeEach(() => { jest.clearAllMocks() + + // reset navigation context mocks + mockGetRelevantSearchParameters.mockReturnValue({}) + mockGetOriginalSearchParameters.mockReturnValue({}) }) it("renders the full table container, table, and table head when prescriptions exist", async () => { @@ -397,9 +431,10 @@ describe("PrescriptionsListTable", () => { expect(prescriptionButton).toBeInTheDocument() fireEvent.click(prescriptionButton) - expect(mockClearSearchParameters).toHaveBeenCalled() - expect(mockSetPrescriptionId).toHaveBeenCalledWith("C0C757-A83008-C2D93O") - expect(mockSetIssueNumber).toHaveBeenCalledWith("1") + expect(mockSetAllSearchParameters).toHaveBeenCalledWith({ + prescriptionId: "C0C757-A83008-C2D93O", + issueNumber: "1" + }) }) }) diff --git a/packages/cpt-ui/__tests__/PrescriptionNotFoundMessage.test.tsx b/packages/cpt-ui/__tests__/PrescriptionNotFoundMessage.test.tsx index 3a5ea0eec6..ffc82e3c74 100644 --- a/packages/cpt-ui/__tests__/PrescriptionNotFoundMessage.test.tsx +++ b/packages/cpt-ui/__tests__/PrescriptionNotFoundMessage.test.tsx @@ -6,6 +6,30 @@ import PrescriptionNotFoundMessage from "@/components/PrescriptionNotFoundMessag import {STRINGS, SEARCH_STRINGS, SEARCH_TYPES} from "@/constants/ui-strings/PrescriptionNotFoundMessageStrings" import {FRONTEND_PATHS} from "@/constants/environment" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" + +const mockGetBackPath = jest.fn() +const mockGoBack = jest.fn() +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: mockGoBack, + getBackPath: mockGetBackPath, + clearNavigation: jest.fn(), + getCurrentEntry: jest.fn(), + getNavigationStack: jest.fn(), + canGoBack: jest.fn(), + setOriginalSearchPage: jest.fn(), + getOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: jest.fn(), + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) const mockClearSearchParameters = jest.fn() const mockSetPrescriptionId = jest.fn() @@ -15,10 +39,12 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() +const mockSetSearchType = jest.fn() + const defaultSearchState: SearchProviderContextType = { prescriptionId: undefined, issueNumber: undefined, @@ -29,6 +55,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -40,24 +67,27 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, getAllSearchParameters: mockGetAllSearchParameters, - setAllSearchParameters: mockSetAllSearchParameters + setAllSearchParameters: mockSetAllSearchParameters, + setSearchType: mockSetSearchType } -const DummyPage = ({label}: {label: string}) =>
{label}
+const DummyPage = ({label}: { label: string }) => ( +
{label}
+) interface searchParams { - prescriptionId?: string - issueNumber?: string - firstName?: string - lastName?: string - dobDay?: string - dobMonth?: string - dobYear?: string - postcode?: string - nhsNumber?: string + prescriptionId?: string; + issueNumber?: string; + firstName?: string; + lastName?: string; + dobDay?: string; + dobMonth?: string; + dobYear?: string; + postcode?: string; + nhsNumber?: string; } const defaultSearchParams: searchParams = { - firstName:"Zoe", + firstName: "Zoe", lastName: "Zero", dobDay: "31", dobMonth: "12", @@ -66,11 +96,20 @@ const defaultSearchParams: searchParams = { } // Helper to DRY test setup for different query params -function setupRouter( - searchParams: searchParams = defaultSearchParams -) { +function setupRouter(searchParams: searchParams = {}) { + let backPath = FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + if (searchParams.prescriptionId) { + backPath = FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + } else if (searchParams.nhsNumber) { + backPath = FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + } else if (searchParams.firstName || searchParams.lastName) { + backPath = FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + } + mockGetBackPath.mockReturnValue(backPath) const searchState = { ...defaultSearchState, + prescriptionId: searchParams.prescriptionId, + issueNumber: searchParams.issueNumber, firstName: searchParams.firstName, lastName: searchParams.lastName, dobDay: searchParams.dobDay, @@ -82,27 +121,32 @@ function setupRouter( render( - - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + + ) } describe("PrescriptionNotFoundMessage", () => { + beforeEach(() => { + jest.clearAllMocks() + }) it("renders the main heading and static content for basic details search", () => { - setupRouter() + setupRouter(defaultSearchParams) const headings = screen.getAllByTestId("presc-not-found-heading") expect(headings[0]).toHaveTextContent(STRINGS.heading) }) it("renders the main container with correct id and class", () => { - setupRouter() + setupRouter(defaultSearchParams) const mainElement = screen.getByRole("main") expect(mainElement).toBeInTheDocument() expect(mainElement).toHaveAttribute("id", "main-content") @@ -110,14 +154,14 @@ describe("PrescriptionNotFoundMessage", () => { }) it("renders the back link with correct text for basic details search", () => { - setupRouter() + setupRouter(defaultSearchParams) const link = screen.getByTestId("go-back-link") expect(link).toHaveTextContent(STRINGS.goBackLink) expect(link.getAttribute("href")).toContain(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) }) it("renders all body paragraphs and alternative links for basic details search", () => { - setupRouter() + setupRouter(defaultSearchParams) const querySummary = screen.getByTestId("query-summary") // First paragraph @@ -136,7 +180,7 @@ describe("PrescriptionNotFoundMessage", () => { // Middle paragraph: two links const links = within(querySummary).getAllByRole("link") - const altLabels = links.map(link => link.textContent) + const altLabels = links.map((link) => link.textContent) expect(altLabels).toEqual( expect.arrayContaining([ "search using a prescription ID", @@ -153,26 +197,60 @@ describe("PrescriptionNotFoundMessage", () => { nhsNumber: "9912003071" }) const link = screen.getByTestId("go-back-link") - expect(link.getAttribute("href")).toContain(FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) + expect(link.getAttribute("href")).toContain( + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + ) const querySummary = screen.getByTestId("query-summary") + // should show the NHS number search body text + expect(querySummary).toHaveTextContent( + "We could not find any prescriptions using the NHS number you searched for." + ) + // Should offer prescription ID and basic details as alternative links - const altLinks = within(querySummary).getAllByRole("link").map(l => l.textContent) - const nhsNumberAltLabels = SEARCH_STRINGS[SEARCH_TYPES.NHS_NUMBER].alternatives.map(a => a.label) + const altLinks = within(querySummary) + .getAllByRole("link") + .map((l) => l.textContent) + const nhsNumberAltLabels = SEARCH_STRINGS[ + SEARCH_TYPES.NHS_NUMBER + ].alternatives.map((a) => a.label) expect(altLinks).toEqual(expect.arrayContaining(nhsNumberAltLabels)) }) + it("renders correct navigation for NHS number search with different context", () => { + mockGetAllSearchParameters.mockReturnValue({ + nhsNumber: "9912003071" + }) + setupRouter({ + nhsNumber: "9912003071" + }) + const link = screen.getByTestId("go-back-link") + expect(link.getAttribute("href")).toContain( + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + ) + }) + it("renders correct navigation and content for Prescription ID search", () => { setupRouter({prescriptionId: "9000000001"}) const link = screen.getByTestId("go-back-link") - expect(link.getAttribute("href")).toContain(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + expect(link.getAttribute("href")).toContain( + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + ) const querySummary = screen.getByTestId("query-summary") // Should offer NHS number and basic details as alternative links const altLinks = within(querySummary).getAllByRole("link").map(l => l.textContent) - const expectedAltLabels = SEARCH_STRINGS[SEARCH_TYPES.BASIC_DETAILS].alternatives.map(a => a.label) + const expectedAltLabels = SEARCH_STRINGS[SEARCH_TYPES.PRESCRIPTION_ID].alternatives.map(a => a.label) expect(altLinks).toEqual(expect.arrayContaining(expectedAltLabels)) }) + + it("renders correct navigation for NHS number search", () => { + setupRouter({nhsNumber: "9912003071"}) + const link = screen.getByTestId("go-back-link") + expect(link.getAttribute("href")).toContain( + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + ) + }) }) diff --git a/packages/cpt-ui/__tests__/SearchProvider.test.tsx b/packages/cpt-ui/__tests__/SearchProvider.test.tsx index 13df24b39b..9e23f1c690 100644 --- a/packages/cpt-ui/__tests__/SearchProvider.test.tsx +++ b/packages/cpt-ui/__tests__/SearchProvider.test.tsx @@ -1,5 +1,6 @@ import React from "react" import {render, screen} from "@testing-library/react" +import {MemoryRouter} from "react-router-dom" import {SearchProvider, useSearchContext} from "@/context/SearchProvider" import userEvent from "@testing-library/user-event" @@ -27,9 +28,11 @@ const TestComponent = () => { describe("SearchProvider", () => { it("provides default values", () => { render( - - - + + + + + ) expect(screen.getByText(/Prescription ID: null/)).toBeInTheDocument() @@ -38,9 +41,11 @@ describe("SearchProvider", () => { it("sets values correctly", async () => { render( - - - + + + + + ) const user = userEvent.setup() @@ -53,9 +58,11 @@ describe("SearchProvider", () => { it("clears values with clearSearchParameters", async () => { render( - - - + + + + + ) const user = userEvent.setup() diff --git a/packages/cpt-ui/__tests__/SearchResultsTooManyMessage.test.tsx b/packages/cpt-ui/__tests__/SearchResultsTooManyMessage.test.tsx index b9a785725e..1203e814cc 100644 --- a/packages/cpt-ui/__tests__/SearchResultsTooManyMessage.test.tsx +++ b/packages/cpt-ui/__tests__/SearchResultsTooManyMessage.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import "@testing-library/jest-dom" import {render, screen, fireEvent} from "@testing-library/react" import { @@ -11,9 +12,34 @@ import React from "react" import SearchResultsTooManyMessage from "@/components/SearchResultsTooManyMessage" import {STRINGS} from "@/constants/ui-strings/SearchResultsTooManyStrings" import {FRONTEND_PATHS} from "@/constants/environment" +import {NavigationProvider} from "@/context/NavigationProvider" + +// Mock the navigation context +const mockGetBackPath = jest.fn() +const mockGoBack = jest.fn() +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: mockGoBack, + getBackPath: mockGetBackPath, + clearNavigation: jest.fn(), + getCurrentEntry: jest.fn(), + getNavigationStack: jest.fn(), + canGoBack: jest.fn(), + setOriginalSearchPage: jest.fn(), + getOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: jest.fn(), + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) // Dummy component to receive navigation -const DummyPage = ({label}: {label: string}) =>
{label}
+const DummyPage = ({label}: { label: string }) =>
{label}
// useLocation wrapper to pass .search to the component function TestWrapper() { @@ -34,17 +60,23 @@ const renderWithRouter = ( const search = makeQuery(queryParams) return render( - - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + + ) } describe("SearchResultsTooManyMessage", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + }) it("renders static text content", () => { renderWithRouter() expect(screen.getByTestId("too-many-results-heading")).toHaveTextContent(STRINGS.heading) @@ -79,7 +111,11 @@ describe("SearchResultsTooManyMessage", () => { it("navigates to the basic details search when the go back link is clicked", () => { renderWithRouter() - fireEvent.click(screen.getByTestId("go-back-link")) - expect(screen.getByTestId("dummy-page")).toHaveTextContent("Basic Details Search") + const backLink = screen.getByTestId("go-back-link") + expect(backLink.getAttribute("href")).toContain( + FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + ) + fireEvent.click(backLink) + expect(mockGoBack).toHaveBeenCalled() }) }) diff --git a/packages/cpt-ui/__tests__/UnknownErrorMessage.test.tsx b/packages/cpt-ui/__tests__/UnknownErrorMessage.test.tsx index 760ced70a8..8408704ffc 100644 --- a/packages/cpt-ui/__tests__/UnknownErrorMessage.test.tsx +++ b/packages/cpt-ui/__tests__/UnknownErrorMessage.test.tsx @@ -4,6 +4,30 @@ import {MemoryRouter, Routes, Route} from "react-router-dom" import UnknownErrorMessage from "@/components/UnknownErrorMessage" import {FRONTEND_PATHS} from "@/constants/environment" import {SearchContext, SearchProviderContextType} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" + +const mockGetBackPath = jest.fn() +const mockGoBack = jest.fn() +const mockNavigationContext = { + pushNavigation: jest.fn(), + goBack: mockGoBack, + getBackPath: mockGetBackPath, + clearNavigation: jest.fn(), + getCurrentEntry: jest.fn(), + getNavigationStack: jest.fn(), + canGoBack: jest.fn(), + setOriginalSearchPage: jest.fn(), + getOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: jest.fn(), + startNewNavigationSession: jest.fn() +} + +jest.mock("@/context/NavigationProvider", () => ({ + ...jest.requireActual("@/context/NavigationProvider"), + useNavigationContext: () => mockNavigationContext +})) const mockClearSearchParameters = jest.fn() const mockSetPrescriptionId = jest.fn() @@ -13,10 +37,12 @@ const mockSetLastName = jest.fn() const mockSetDobDay = jest.fn() const mockSetDobMonth = jest.fn() const mockSetDobYear = jest.fn() -const mockSetPostcode =jest.fn() +const mockSetPostcode = jest.fn() const mockSetNhsNumber = jest.fn() const mockGetAllSearchParameters = jest.fn() const mockSetAllSearchParameters = jest.fn() +const mockSetSearchType = jest.fn() + const defaultSearchState: SearchProviderContextType = { prescriptionId: undefined, issueNumber: undefined, @@ -27,6 +53,7 @@ const defaultSearchState: SearchProviderContextType = { dobYear: undefined, postcode: undefined, nhsNumber: undefined, + searchType: undefined, clearSearchParameters: mockClearSearchParameters, setPrescriptionId: mockSetPrescriptionId, setIssueNumber: mockSetIssueNumber, @@ -38,7 +65,8 @@ const defaultSearchState: SearchProviderContextType = { setPostcode: mockSetPostcode, setNhsNumber: mockSetNhsNumber, getAllSearchParameters: mockGetAllSearchParameters, - setAllSearchParameters: mockSetAllSearchParameters + setAllSearchParameters: mockSetAllSearchParameters, + setSearchType: mockSetSearchType } // A simple error boundary for testing purposes @@ -67,16 +95,23 @@ function ErrorThrowingComponent(): React.ReactElement { } describe("UnknownErrorMessage", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + }) + it("renders fallback UI when an unexpected error occurs", () => { render( - - - - } /> - - - + + + + + } /> + + + + ) diff --git a/packages/cpt-ui/__tests__/useBackNavigation.test.tsx b/packages/cpt-ui/__tests__/useBackNavigation.test.tsx new file mode 100644 index 0000000000..f03d0dbf65 --- /dev/null +++ b/packages/cpt-ui/__tests__/useBackNavigation.test.tsx @@ -0,0 +1,124 @@ +import {renderHook} from "@testing-library/react" +import {useBackNavigation} from "@/hooks/useBackNavigation" +import {useNavigationContext} from "@/context/NavigationProvider" +import {FRONTEND_PATHS} from "@/constants/environment" + +jest.mock("@/context/NavigationProvider", () => ({ + useNavigationContext: jest.fn() +})) + +const mockUseNavigationContext = useNavigationContext as jest.MockedFunction< + typeof useNavigationContext +> + +describe("useBackNavigation", () => { + const mockGoBack = jest.fn() + const mockGetBackPath = jest.fn() + + beforeEach(() => { + mockUseNavigationContext.mockReturnValue({ + goBack: mockGoBack, + getBackPath: mockGetBackPath, + pushNavigation: jest.fn(), + setOriginalSearchPage: jest.fn(), + captureOriginalSearchParameters: jest.fn(), + getOriginalSearchParameters: jest.fn(), + getRelevantSearchParameters: jest.fn(), + startNewNavigationSession: jest.fn() + }) + jest.clearAllMocks() + }) + + describe("getBackLink", () => { + it("returns navigation context back path when available", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) + + const {result} = renderHook(() => useBackNavigation()) + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + ) + }) + + it("returns default search when navigation context returns null", () => { + mockGetBackPath.mockReturnValue(null) + + const {result} = renderHook(() => useBackNavigation()) + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + ) + }) + + it("returns NHS number search from navigation context", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) + + const {result} = renderHook(() => useBackNavigation()) + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + ) + }) + + it("returns basic details search from navigation context", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + + const {result} = renderHook(() => useBackNavigation()) + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + ) + }) + + it("returns basic details search from patient search results page", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS) + + const {result} = renderHook(() => useBackNavigation()) + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + ) + }) + }) + + describe("goBack behavior", () => { + it("calls navigation context goBack", () => { + const {result} = renderHook(() => useBackNavigation()) + + result.current.goBack() + + expect(mockGoBack).toHaveBeenCalledTimes(1) + }) + + it("delegates navigation logic to navigation context", () => { + const {result} = renderHook(() => useBackNavigation()) + + result.current.goBack() + result.current.goBack() + + expect(mockGoBack).toHaveBeenCalledTimes(2) + }) + }) + + describe("hook dependencies", () => { + it("updates when navigation context changes", () => { + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) + + const {result, rerender} = renderHook(() => useBackNavigation()) + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + ) + + mockGetBackPath.mockReturnValue(FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER) + + rerender() + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER + ) + }) + + it("handles navigation context returning null", () => { + mockGetBackPath.mockReturnValue(null) + + const {result} = renderHook(() => useBackNavigation()) + expect(result.current.getBackLink()).toBe( + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + ) + }) + }) +}) diff --git a/packages/cpt-ui/src/App.tsx b/packages/cpt-ui/src/App.tsx index 19c20c9c79..619c2f4538 100644 --- a/packages/cpt-ui/src/App.tsx +++ b/packages/cpt-ui/src/App.tsx @@ -2,6 +2,7 @@ import {Routes, Route} from "react-router-dom" import {AuthProvider} from "@/context/AuthProvider" import {AccessProvider} from "@/context/AccessProvider" import {SearchProvider} from "@/context/SearchProvider" +import {NavigationProvider} from "@/context/NavigationProvider" import {PatientDetailsProvider} from "./context/PatientDetailsProvider" import {PrescriptionInformationProvider} from "./context/PrescriptionInformationProvider" import Layout from "@/Layout" @@ -33,32 +34,34 @@ export default function App() { - - }> - {/* Public cookie routes */} - } /> - } /> + + + }> + {/* Public cookie routes */} + } /> + } /> - {/* Your existing routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + {/* Your existing routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + diff --git a/packages/cpt-ui/src/components/EpsBackLink.tsx b/packages/cpt-ui/src/components/EpsBackLink.tsx new file mode 100644 index 0000000000..177af9f95c --- /dev/null +++ b/packages/cpt-ui/src/components/EpsBackLink.tsx @@ -0,0 +1,25 @@ +import React from "react" +import {BackLink} from "nhsuk-react-components" +import {useBackNavigation} from "@/hooks/useBackNavigation" + +export default function EpsBackLink({children, ...props}: React.ComponentProps) { + const {goBack, getBackLink} = useBackNavigation() + + const handleGoBack = (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault() + goBack() + } + + return ( + e.key === "Enter" && handleGoBack(e)} + {...props} + > + {children} + + ) +} diff --git a/packages/cpt-ui/src/components/PatientNotFoundMessage.tsx b/packages/cpt-ui/src/components/PatientNotFoundMessage.tsx index 9bef2a1494..02fa62048f 100644 --- a/packages/cpt-ui/src/components/PatientNotFoundMessage.tsx +++ b/packages/cpt-ui/src/components/PatientNotFoundMessage.tsx @@ -1,31 +1,20 @@ import React from "react" -import { - Container, - Row, - Col, - BackLink -} from "nhsuk-react-components" +import {Container, Row, Col} from "nhsuk-react-components" import {Link} from "react-router-dom" import {FRONTEND_PATHS} from "@/constants/environment" import {STRINGS} from "@/constants/ui-strings/PatientNotFoundMessageStrings" +import EpsBackLink from "@/components/EpsBackLink" -type PatientNotFoundMessageProps = { - readonly search?: string -} - -export default function PatientNotFoundMessage({search = ""}: PatientNotFoundMessageProps) { +export default function PatientNotFoundMessage() { return (
- + {STRINGS.goBackLink} - +
{ switch (prescriptionType) { case "0001": @@ -65,7 +65,9 @@ const PrescriptionsListTable = ({ case "0002": return PRESCRIPTION_LIST_TABLE_TEXT.typeDisplayText.repeat case "0003": - return PRESCRIPTION_LIST_TABLE_TEXT.typeDisplayText.erd.replace("Y", repeatIssue?.toString()).replace("X", repeatMax!.toString()) + return PRESCRIPTION_LIST_TABLE_TEXT.typeDisplayText.erd + .replace("Y", repeatIssue?.toString()) + .replace("X", repeatMax!.toString()) default: return PRESCRIPTION_LIST_TABLE_TEXT.typeDisplayText.unknown } @@ -91,13 +93,23 @@ const PrescriptionsListTable = ({ aria-label={PRESCRIPTION_LIST_TABLE_TEXT.sortLabel} > ▼ @@ -128,10 +140,36 @@ const PrescriptionsListTable = ({ return `${FRONTEND_PATHS.PRESCRIPTION_DETAILS_PAGE}` } - const setSearchPrescriptionState = (prescriptionId: string, issueNumber: string | undefined) => { - searchContext.clearSearchParameters() - searchContext.setPrescriptionId(prescriptionId) - searchContext.setIssueNumber(issueNumber) + const setSearchPrescriptionState = ( + prescriptionId: string, + issueNumber: string | undefined + ) => { + const originalSearchParams = + navigationContext.getOriginalSearchParameters() + + let relevantParams = {} + if (originalSearchParams) { + if (originalSearchParams.prescriptionId) { + relevantParams = + navigationContext.getRelevantSearchParameters("prescriptionId") + } else if (originalSearchParams.nhsNumber) { + relevantParams = + navigationContext.getRelevantSearchParameters("nhsNumber") + } else if ( + originalSearchParams.firstName || + originalSearchParams.lastName + ) { + relevantParams = + navigationContext.getRelevantSearchParameters("basicDetails") + } + } + + // only preserve relevant search parameters and add prescription-specific ones + searchContext.setAllSearchParameters({ + ...relevantParams, + prescriptionId, + issueNumber + }) } const getSortedItems = () => { @@ -196,7 +234,7 @@ const PrescriptionsListTable = ({ const intro = PRESCRIPTION_LIST_TABLE_TEXT.caption[ - testid as keyof typeof PRESCRIPTION_LIST_TABLE_TEXT.caption + testid as keyof typeof PRESCRIPTION_LIST_TABLE_TEXT.caption ] const sharedText = PRESCRIPTION_LIST_TABLE_TEXT.caption.sharedText diff --git a/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx b/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx index 29cfa6b5aa..1626b54839 100644 --- a/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx +++ b/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx @@ -20,6 +20,7 @@ import {errorFocusMap, ErrorKey, resolveDobInvalidFields} from "@/helpers/basicD import {STRINGS} from "@/constants/ui-strings/BasicDetailsSearchStrings" import {FRONTEND_PATHS} from "@/constants/environment" import {useSearchContext} from "@/context/SearchProvider" +import {useNavigationContext} from "@/context/NavigationProvider" export default function BasicDetailsSearch() { const navigate = useNavigate() @@ -36,6 +37,7 @@ export default function BasicDetailsSearch() { const inlineErrors = getInlineErrors(errors) const searchContext = useSearchContext() + const navigationContext = useNavigationContext() // Inline error lookup: used to find the error message string for specific field(s) // Returns the first match found in the array of inline error tuples @@ -49,6 +51,19 @@ export default function BasicDetailsSearch() { } }, [errors]) + // restore original search parameters when available + useEffect(() => { + const originalParams = navigationContext.getOriginalSearchParameters() + if (originalParams) { + setFirstName(originalParams.firstName || "") + setLastName(originalParams.lastName || "") + setDobDay(originalParams.dobDay || "") + setDobMonth(originalParams.dobMonth || "") + setDobYear(originalParams.dobYear || "") + setPostcode(originalParams.postcode || "") + } + }, [navigationContext]) + useEffect(() => { // Allows keyboard/screen-reader users to jump to field when clicking summary links // Needed for tests: jsdom doesn't auto-focus elements via href="#field-id" links. @@ -115,10 +130,14 @@ export default function BasicDetailsSearch() { "dobFutureDate" ]) - const hasDobRelatedError = newErrors.some(error => dobErrorKeys.has(error)) + const hasDobRelatedError = newErrors.some((error) => + dobErrorKeys.has(error) + ) if (hasDobRelatedError) { - setDobErrorFields(resolveDobInvalidFields({dobDay, dobMonth, dobYear})) + setDobErrorFields( + resolveDobInvalidFields({dobDay, dobMonth, dobYear}) + ) } else { setDobErrorFields([]) } @@ -126,6 +145,22 @@ export default function BasicDetailsSearch() { return } + //clear any previous search navigation context + navigationContext.startNewNavigationSession() + + // capture original search parameters before clearing + const originalParams = { + firstName, + lastName, + dobDay, + dobMonth, + dobYear, + postcode + } + navigationContext.captureOriginalSearchParameters( + "basicDetails", + originalParams + ) //API requires a 2 digit format i.e. 06 instead of 6 for June const formattedDobDay = dobDay.padStart(2, "0") const formattedDobMonth = dobMonth.padStart(2, "0") @@ -137,6 +172,7 @@ export default function BasicDetailsSearch() { searchContext.setDobMonth(formattedDobMonth) searchContext.setDobYear(dobYear) searchContext.setPostcode(postcode) + searchContext.setSearchType("basicDetails") navigate(FRONTEND_PATHS.PATIENT_SEARCH_RESULTS) } diff --git a/packages/cpt-ui/src/components/prescriptionSearch/NhsNumSearch.tsx b/packages/cpt-ui/src/components/prescriptionSearch/NhsNumSearch.tsx index e16878fdd3..065a69ba93 100644 --- a/packages/cpt-ui/src/components/prescriptionSearch/NhsNumSearch.tsx +++ b/packages/cpt-ui/src/components/prescriptionSearch/NhsNumSearch.tsx @@ -22,19 +22,38 @@ import { import {STRINGS} from "@/constants/ui-strings/NhsNumSearchStrings" import {FRONTEND_PATHS} from "@/constants/environment" import {useSearchContext} from "@/context/SearchProvider" +import {useNavigationContext} from "@/context/NavigationProvider" import {validateNhsNumber, normalizeNhsNumber, NhsNumberValidationError} from "@/helpers/validateNhsNumber" export default function NhsNumSearch() { - const [nhsNumber, setNhsNumber] = useState("") - const [errorKey, setErrorKey] = useState(null) - const errorRef = useRef(null) const navigate = useNavigate() const searchContext = useSearchContext() + const navigationContext = useNavigationContext() + const [nhsNumber, setNhsNumber] = useState( + searchContext.nhsNumber || "" + ) + const [errorKey, setErrorKey] = useState( + null + ) + const errorRef = useRef(null) const errorMessages = STRINGS.errors const displayedError = useMemo(() => errorKey ? errorMessages[errorKey] : "", [errorKey]) + useEffect(() => { + if (searchContext.nhsNumber && searchContext.searchType === "nhs") { + setNhsNumber(searchContext.nhsNumber) + } + }, [searchContext.nhsNumber, searchContext.searchType]) + + useEffect(() => { + const originalParams = navigationContext.getOriginalSearchParameters() + if (originalParams && originalParams.nhsNumber) { + setNhsNumber(originalParams.nhsNumber || "") + } + }, [navigationContext]) + useEffect(() => { if (errorKey && errorRef.current) { errorRef.current.focus() @@ -55,8 +74,21 @@ export default function NhsNumSearch() { } setErrorKey(null) const normalized = normalizeNhsNumber(nhsNumber) + + // clear any previous search context + navigationContext.startNewNavigationSession() + + const originalParams = { + nhsNumber: normalized || "" + } + navigationContext.captureOriginalSearchParameters( + "nhsNumber", + originalParams + ) + searchContext.clearSearchParameters() - searchContext.setNhsNumber(normalized) + searchContext.setNhsNumber(normalized || "") + searchContext.setSearchType("nhs") navigate(`${FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT}`) } diff --git a/packages/cpt-ui/src/components/prescriptionSearch/PrescriptionIdSearch.tsx b/packages/cpt-ui/src/components/prescriptionSearch/PrescriptionIdSearch.tsx index 3e09192d47..a6e2923065 100644 --- a/packages/cpt-ui/src/components/prescriptionSearch/PrescriptionIdSearch.tsx +++ b/packages/cpt-ui/src/components/prescriptionSearch/PrescriptionIdSearch.tsx @@ -29,20 +29,31 @@ import { PrescriptionValidationError } from "@/helpers/validatePrescriptionDetailsSearch" import {useSearchContext} from "@/context/SearchProvider" +import {useNavigationContext} from "@/context/NavigationProvider" export default function PrescriptionIdSearch() { const navigate = useNavigate() const errorRef = useRef(null) const searchContext = useSearchContext() + const navigationContext = useNavigationContext() - const [prescriptionId, setPrescriptionId] = useState("") + const [prescriptionId, setPrescriptionId] = useState(searchContext.prescriptionId || "") const [errorKey, setErrorKey] = useState(null) const errorMessages = PRESCRIPTION_ID_SEARCH_STRINGS.errors + useEffect(() => { + const originalParams = navigationContext.getOriginalSearchParameters() + if (originalParams && originalParams.prescriptionId) { + setPrescriptionId(originalParams.prescriptionId || "") + } + }, [navigationContext]) + // Maps a validation error key to the corresponding user-facing message. // Treats "checksum" as "noMatch" to simplify the error display logic. - const getDisplayedErrorMessage = (key: PrescriptionValidationError | null): string => { + const getDisplayedErrorMessage = ( + key: PrescriptionValidationError | null + ): string => { if (!key) return "" if (key === "noMatch") return errorMessages.noMatch return errorMessages[key] || errorMessages.noMatch @@ -51,6 +62,15 @@ export default function PrescriptionIdSearch() { // Memoised error message for display const displayedError = useMemo(() => getDisplayedErrorMessage(errorKey), [errorKey]) + useEffect(() => { + if ( + searchContext.prescriptionId && + searchContext.searchType === "prescriptionId" + ) { + setPrescriptionId(searchContext.prescriptionId) + } + }, [searchContext.prescriptionId, searchContext.searchType]) + // When error is set, focus error summary useEffect(() => { if (errorKey && errorRef.current) errorRef.current.focus() @@ -74,8 +94,21 @@ export default function PrescriptionIdSearch() { setErrorKey(null) // Clear error on valid submit const formatted = normalizePrescriptionId(prescriptionId) + + //clear previous search context + navigationContext.startNewNavigationSession() + + const originalParams = { + prescriptionId: formatted + } + navigationContext.captureOriginalSearchParameters( + "prescriptionId", + originalParams + ) + searchContext.clearSearchParameters() searchContext.setPrescriptionId(formatted) + searchContext.setSearchType("prescriptionId") navigate(`${FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT}`) } diff --git a/packages/cpt-ui/src/constants/ui-strings/BasicDetailsSearchResultsPageStrings.ts b/packages/cpt-ui/src/constants/ui-strings/BasicDetailsSearchResultsPageStrings.ts index 86bd8876cc..c96928a4c0 100644 --- a/packages/cpt-ui/src/constants/ui-strings/BasicDetailsSearchResultsPageStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/BasicDetailsSearchResultsPageStrings.ts @@ -1,6 +1,6 @@ export const SearchResultsPageStrings = { - GO_BACK: "Go back", - TITLE: "Search Results", + GO_BACK: "Back", + TITLE: "Search results", RESULTS_COUNT: "We found {count} results.", LOADING: "Loading search results", TABLE: { diff --git a/packages/cpt-ui/src/constants/ui-strings/HeaderStrings.ts b/packages/cpt-ui/src/constants/ui-strings/HeaderStrings.ts index 1aa54b3d75..9eb7c250f3 100644 --- a/packages/cpt-ui/src/constants/ui-strings/HeaderStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/HeaderStrings.ts @@ -2,7 +2,7 @@ import {FRONTEND_PATHS} from "@/constants/environment" // HEADER strings export const HEADER_SERVICE = "Prescription Tracker (pilot)" -export const HEADER_CHANGE_ROLE_BUTTON = "Change Role" +export const HEADER_CHANGE_ROLE_BUTTON = "Change role" export const HEADER_CHANGE_ROLE_TARGET = FRONTEND_PATHS.CHANGE_YOUR_ROLE export const HEADER_SELECT_YOUR_ROLE_BUTTON = "Select Your Role" export const HEADER_SELECT_YOUR_ROLE_TARGET = FRONTEND_PATHS.SELECT_YOUR_ROLE diff --git a/packages/cpt-ui/src/constants/ui-strings/PatientNotFoundMessageStrings.ts b/packages/cpt-ui/src/constants/ui-strings/PatientNotFoundMessageStrings.ts index 4c6ef5cd3d..d6a78264cf 100644 --- a/packages/cpt-ui/src/constants/ui-strings/PatientNotFoundMessageStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/PatientNotFoundMessageStrings.ts @@ -7,5 +7,5 @@ export const STRINGS = { orText: " or ", nhsNumberLinkText: "search using an NHS number", endPunctuation: ".", - goBackLink: "Go back" + goBackLink: "Back" } diff --git a/packages/cpt-ui/src/constants/ui-strings/PrescriptionDetailsPageStrings.ts b/packages/cpt-ui/src/constants/ui-strings/PrescriptionDetailsPageStrings.ts index bbd7f2b51f..402d53e867 100644 --- a/packages/cpt-ui/src/constants/ui-strings/PrescriptionDetailsPageStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/PrescriptionDetailsPageStrings.ts @@ -1,5 +1,5 @@ export const STRINGS = { HEADER: "Prescription details", LOADING_FULL_PRESCRIPTION: "Loading full prescription", - GO_BACK: "Go back" + GO_BACK: "Back" } diff --git a/packages/cpt-ui/src/constants/ui-strings/PrescriptionListPageStrings.ts b/packages/cpt-ui/src/constants/ui-strings/PrescriptionListPageStrings.ts index 5723084690..1f0a302820 100644 --- a/packages/cpt-ui/src/constants/ui-strings/PrescriptionListPageStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/PrescriptionListPageStrings.ts @@ -4,7 +4,7 @@ export const PRESCRIPTION_LIST_PAGE_STRINGS = { PAGE_TITLE: "Prescriptions list", HEADING: "Prescriptions list", LOADING_MESSAGE: "Loading search results", - GO_BACK_LINK_TEXT: "Go back", + GO_BACK_LINK_TEXT: "Back", RESULTS_PREFIX: "We found ", RESULTS_SUFFIX: " results", DEFAULT_BACK_LINK_TARGET: FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID, diff --git a/packages/cpt-ui/src/constants/ui-strings/PrescriptionNotFoundMessageStrings.ts b/packages/cpt-ui/src/constants/ui-strings/PrescriptionNotFoundMessageStrings.ts index d583dba723..ca68df540e 100644 --- a/packages/cpt-ui/src/constants/ui-strings/PrescriptionNotFoundMessageStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/PrescriptionNotFoundMessageStrings.ts @@ -64,7 +64,7 @@ export const SEARCH_STRINGS = { export const STRINGS = { heading: "No prescriptions found", - goBackLink: "Go back" + goBackLink: "Back" } as const export const SEARCH_TYPES = { diff --git a/packages/cpt-ui/src/constants/ui-strings/SearchResultsTooManyStrings.ts b/packages/cpt-ui/src/constants/ui-strings/SearchResultsTooManyStrings.ts index 5dd4bc222f..b016260b55 100644 --- a/packages/cpt-ui/src/constants/ui-strings/SearchResultsTooManyStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/SearchResultsTooManyStrings.ts @@ -9,5 +9,5 @@ export const STRINGS = { orText: " or ", prescriptionIdLinkText: "search using a prescription ID", endPunctuation: ".", - goBackLink: "Go back" + goBackLink: "Back" } diff --git a/packages/cpt-ui/src/constants/ui-strings/UnknownErrorMessageStrings.ts b/packages/cpt-ui/src/constants/ui-strings/UnknownErrorMessageStrings.ts index 2b7c7bd07b..94d00095fe 100644 --- a/packages/cpt-ui/src/constants/ui-strings/UnknownErrorMessageStrings.ts +++ b/packages/cpt-ui/src/constants/ui-strings/UnknownErrorMessageStrings.ts @@ -1,5 +1,5 @@ export const STRINGS = { heading: "Sorry, there is a problem with this service", intro: "Try again later.", - goBackLink: "Go back" + goBackLink: "Back" } diff --git a/packages/cpt-ui/src/context/NavigationProvider.tsx b/packages/cpt-ui/src/context/NavigationProvider.tsx new file mode 100644 index 0000000000..a921b14f79 --- /dev/null +++ b/packages/cpt-ui/src/context/NavigationProvider.tsx @@ -0,0 +1,344 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect +} from "react" +import {useNavigate, useLocation} from "react-router-dom" +import {FRONTEND_PATHS} from "@/constants/environment" +import {logger} from "@/helpers/logger" + +export type NavigationEntry = { + path: string; +}; + +export interface NavigationContextType { + pushNavigation: (path: string) => void; + goBack: () => void; + getBackPath: () => string | null; + + setOriginalSearchPage: ( + searchType: "prescriptionId" | "nhsNumber" | "basicDetails", + ) => void + + captureOriginalSearchParameters: ( + searchType: "prescriptionId" | "nhsNumber" | "basicDetails", + searchParams: Record, + ) => void; + getOriginalSearchParameters: () => Record | null; + getRelevantSearchParameters: ( + searchType: "prescriptionId" | "nhsNumber" | "basicDetails", + ) => Record; + + startNewNavigationSession: () => void; +} + +const NavigationContext = createContext(null) + +export const useNavigationContext = () => { + const context = useContext(NavigationContext) + if (!context) { + throw new Error( + "useNavigationContext must be used within NavigationProvider" + ) + } + return context +} + +interface NavigationProviderProps { + children: React.ReactNode; +} + +export const NavigationProvider: React.FC = ({ + children +}) => { + const navigate = useNavigate() + const location = useLocation() + + const [navigationStack, setNavigationStack] = useState>([]) + const [originalSearchPage, setOriginalSearchPageState] = useState<{ path: string} | null>(null) + const [originalSearchParameters, setOriginalSearchParameters] = useState<{ + searchType: "prescriptionId" | "nhsNumber" | "basicDetails"; + params: Record + } | null>(null) + + // prescription list pages that should be treated as one logical unit + const prescriptionListPages = [ + FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT, + FRONTEND_PATHS.PRESCRIPTION_LIST_FUTURE, + FRONTEND_PATHS.PRESCRIPTION_LIST_PAST + ] + + const searchPages = [ + FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID, + FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER, + FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + ] + + const pushNavigation = useCallback( + (path: string) => { + const newEntry: NavigationEntry = { + path + } + + setNavigationStack((prev) => { + if (prev.length > 0 && prev[prev.length - 1].path === path) { + return prev + } + + // special handling for prescription details navigation + if (path === FRONTEND_PATHS.PRESCRIPTION_DETAILS_PAGE) { + return [...prev, newEntry] + } + + // if navigating to a prescription list page and we already have one in stack, + // replace it instead of adding a new entry (treating tabs as one logical page) + if (prescriptionListPages.includes(path)) { + const lastEntry = prev[prev.length - 1] + if (lastEntry && prescriptionListPages.includes(lastEntry.path)) { + return [...prev.slice(0, -1), newEntry] + } + } + + logger.info("Navigation: Pushing entry", { + path, + stackLength: prev.length + 1 + }) + return [...prev, newEntry] + }) + }, + [prescriptionListPages] + ) + + const setOriginalSearchPage = useCallback( + (searchType: "prescriptionId" | "nhsNumber" | "basicDetails") => { + const searchPaths = { + prescriptionId: FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID, + nhsNumber: FRONTEND_PATHS.SEARCH_BY_NHS_NUMBER, + basicDetails: FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS + } + + const searchPage = { + path: searchPaths[searchType] + } + + logger.info("Navigation: Setting original search page", searchPage) + setOriginalSearchPageState(searchPage) + }, + [] + ) + + const captureOriginalSearchParameters = useCallback( + ( + searchType: "prescriptionId" | "nhsNumber" | "basicDetails", + searchParams: Record + ) => { + logger.info("Navigation: Capturing original search parameters", { + searchType, + params: searchParams + }) + setOriginalSearchParameters({ + searchType, + params: {...searchParams} + }) + + // clear navigation stack to start fresh navigation session and set the original search page + setNavigationStack([]) + setOriginalSearchPage(searchType) + }, + [setOriginalSearchPage] + ) + + const getOriginalSearchParameters = useCallback(() => { + return originalSearchParameters?.params || null + }, [originalSearchParameters]) + + const getRelevantSearchParameters = useCallback( + (searchType: "prescriptionId" | "nhsNumber" | "basicDetails") => { + if ( + !originalSearchParameters || + originalSearchParameters.searchType !== searchType + ) { + return {} + } + + // return only parameters relevant to the specified search type + const relevantKeys = { + prescriptionId: ["prescriptionId", "issueNumber"], + nhsNumber: ["nhsNumber"], + basicDetails: [ + "firstName", + "lastName", + "dobDay", + "dobMonth", + "dobYear", + "postcode" + ] + } + + const keys = relevantKeys[searchType] || [] + const relevantParams: Record = {} + + keys.forEach((key) => { + if (originalSearchParameters.params[key] !== undefined) { + relevantParams[key] = originalSearchParameters.params[key] + } + }) + + logger.info("Navigation: Getting relevant search parameters", { + searchType, + originalParams: originalSearchParameters.params, + relevantParams + }) + + return relevantParams + }, + [originalSearchParameters] + ) + + const getBackPath = useCallback((): string | null => { + const currentPath = location.pathname + + // if we're on prescription details page, find the last prescription list page in stack + if (currentPath === FRONTEND_PATHS.PRESCRIPTION_DETAILS_PAGE) { + for (let i = navigationStack.length - 1; i >= 0; i--) { + const entry = navigationStack[i] + if (prescriptionListPages.includes(entry.path)) { + return entry.path + } + } + return FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT + } + + if (originalSearchPage && !prescriptionListPages.includes(currentPath)) { + return originalSearchPage.path + } + + if (prescriptionListPages.includes(currentPath)) { + if (originalSearchPage) { + return originalSearchPage.path + } + // fallback to navigation stack + if (navigationStack.length >= 2) { + // find the last non-prescription-list entry + for (let i = navigationStack.length - 2; i >= 0; i--) { + if (!prescriptionListPages.includes(navigationStack[i].path)) { + return navigationStack[i].path + } + } + } + } + + if (navigationStack.length >= 2) { + return navigationStack[navigationStack.length - 2].path + } + + return null + }, [ + location.pathname, + navigationStack, + originalSearchPage, + prescriptionListPages + ]) + + const goBack = useCallback(() => { + const backPath = getBackPath() + const currentPath = location.pathname + + logger.info("Navigation: Going back", { + from: currentPath, + to: backPath, + stackLength: navigationStack.length, + originalSearchPage + }) + + if (!backPath) { + logger.warn("Navigation: No back path found, staying on current page") + return + } + + setNavigationStack((prev) => { + // if going back to original search page from prescription list, clear the stack + if ( + prescriptionListPages.includes(currentPath) && + originalSearchPage?.path === backPath + ) { + return [{path: backPath}] + } + + // if going back from prescription details to list, remove details entry + if ( + currentPath === FRONTEND_PATHS.PRESCRIPTION_DETAILS_PAGE && + prescriptionListPages.includes(backPath) + ) { + return prev.filter( + (entry) => entry.path !== FRONTEND_PATHS.PRESCRIPTION_DETAILS_PAGE + ) + } + + return prev.slice(0, -1) + }) + + navigate(backPath) + }, [ + getBackPath, + location.pathname, + navigate, + navigationStack, + originalSearchPage, + prescriptionListPages + ]) + + const startNewNavigationSession = useCallback(() => { + setNavigationStack([]) + setOriginalSearchPageState(null) + setOriginalSearchParameters(null) + }, []) + + useEffect(() => { + const currentPath = location.pathname + + const currentEntry = + navigationStack.length > 0 + ? navigationStack[navigationStack.length - 1] + : null + if (currentEntry?.path === currentPath) { + return + } + + if (searchPages.includes(currentPath) && !originalSearchPage) { + const searchType = currentPath.includes("prescription-id") + ? "prescriptionId" + : currentPath.includes("nhs-number") + ? "nhsNumber" + : "basicDetails" + setOriginalSearchPage(searchType) + } + + pushNavigation(currentPath) + }, [ + location.pathname, + navigationStack, + originalSearchPage, + pushNavigation, + searchPages + ]) + + const contextValue: NavigationContextType = { + pushNavigation, + goBack, + getBackPath, + setOriginalSearchPage, + captureOriginalSearchParameters, + getOriginalSearchParameters, + getRelevantSearchParameters, + startNewNavigationSession + } + + return ( + + {children} + + ) +} diff --git a/packages/cpt-ui/src/context/SearchProvider.tsx b/packages/cpt-ui/src/context/SearchProvider.tsx index f942e3cf80..6e66e4f52b 100644 --- a/packages/cpt-ui/src/context/SearchProvider.tsx +++ b/packages/cpt-ui/src/context/SearchProvider.tsx @@ -10,6 +10,7 @@ export interface SearchParameters { dobYear?: string postcode?: string nhsNumber?: string + searchType?: "nhs" | "prescriptionId" | "basicDetails" } export interface SearchProviderContextType { @@ -22,6 +23,7 @@ export interface SearchProviderContextType { dobYear?: string postcode?: string nhsNumber?: string + searchType?: "nhs" | "prescriptionId" | "basicDetails" clearSearchParameters: () => void setPrescriptionId: (prescriptionId: string | undefined) => void setIssueNumber: (issueNumber: string | undefined) => void @@ -32,6 +34,9 @@ export interface SearchProviderContextType { setDobYear: (dobYear: string | undefined) => void setPostcode: (postCode: string | undefined) => void setNhsNumber: (nhsNumber: string | undefined) => void + setSearchType: ( + searchType: "nhs" | "prescriptionId" | "basicDetails" | undefined, + ) => void getAllSearchParameters: () => SearchParameters setAllSearchParameters: (searchParameters: SearchParameters) => void } @@ -48,6 +53,9 @@ export const SearchProvider = ({children}: { children: React.ReactNode }) => { const [dobYear, setDobYear] = useState(undefined) const [postcode, setPostcode] = useState(undefined) const [nhsNumber, setNhsNumber] = useState(undefined) + const [searchType, setSearchType] = useState< + "nhs" | "prescriptionId" | "basicDetails" | undefined + >(undefined) const clearSearchParameters = () => { setPrescriptionId(undefined) @@ -59,6 +67,7 @@ export const SearchProvider = ({children}: { children: React.ReactNode }) => { setDobYear(undefined) setPostcode(undefined) setNhsNumber(undefined) + setSearchType(undefined) } const setAllSearchParameters = (searchParameters: SearchParameters) => { @@ -71,6 +80,7 @@ export const SearchProvider = ({children}: { children: React.ReactNode }) => { setDobYear(searchParameters.dobYear) setPostcode(searchParameters.postcode) setNhsNumber(searchParameters.nhsNumber) + setSearchType(searchParameters.searchType) } const getAllSearchParameters = () => { @@ -83,33 +93,39 @@ export const SearchProvider = ({children}: { children: React.ReactNode }) => { dobMonth, dobYear, postcode, - nhsNumber + nhsNumber, + searchType } } + return ( - + {children} ) diff --git a/packages/cpt-ui/src/hooks/useBackNavigation.tsx b/packages/cpt-ui/src/hooks/useBackNavigation.tsx new file mode 100644 index 0000000000..8ae48d9f9b --- /dev/null +++ b/packages/cpt-ui/src/hooks/useBackNavigation.tsx @@ -0,0 +1,21 @@ +import {useCallback} from "react" +import {useNavigationContext} from "@/context/NavigationProvider" +import {FRONTEND_PATHS} from "@/constants/environment" + +export const useBackNavigation = () => { + const navigationContext = useNavigationContext() + + const getBackLink = useCallback(() => { + const backPath = navigationContext.getBackPath() + return backPath || FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID + }, [navigationContext]) + + const goBack = useCallback(() => { + navigationContext.goBack() + }, [navigationContext]) + + return { + getBackLink, + goBack + } +} diff --git a/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx b/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx index 0f7d9a89f4..c813715de0 100644 --- a/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx +++ b/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx @@ -1,8 +1,6 @@ -/* eslint-disable max-len */ -import React, {useEffect, useState} from "react" +import React, {Fragment, useEffect, useState} from "react" import {useNavigate, useLocation} from "react-router-dom" import { - BackLink, Table, Container, Row, @@ -18,6 +16,8 @@ import EpsSpinner from "@/components/EpsSpinner" import PatientNotFoundMessage from "@/components/PatientNotFoundMessage" import SearchResultsTooManyMessage from "@/components/SearchResultsTooManyMessage" import {useSearchContext} from "@/context/SearchProvider" +import {useNavigationContext} from "@/context/NavigationProvider" +import EpsBackLink from "@/components/EpsBackLink" import UnknownErrorMessage from "@/components/UnknownErrorMessage" import axios from "axios" import {useAuth} from "@/context/AuthProvider" @@ -29,6 +29,8 @@ export default function SearchResultsPage() { const [loading, setLoading] = useState(true) const [patients, setPatients] = useState>([]) const searchContext = useSearchContext() + const navigationContext = useNavigationContext() + const [error, setError] = useState(false) const auth = useAuth() @@ -38,8 +40,8 @@ export default function SearchResultsPage() { }, []) const getSearchResults = async () => { - try{ - // Attempt to fetch live search results from the API + try { + // Attempt to fetch live search results from the API const response = await http.get(API_ENDPOINTS.PATIENT_SEARCH, { params: { familyName: searchContext.lastName, @@ -61,8 +63,12 @@ export default function SearchResultsPage() { } if (payload.length === 1) { - searchContext.clearSearchParameters() - searchContext.setNhsNumber(payload[0].nhsNumber) + const relevantParams = + navigationContext.getRelevantSearchParameters("basicDetails") + searchContext.setAllSearchParameters({ + ...relevantParams, + nhsNumber: payload[0].nhsNumber + }) navigate(FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT) return } @@ -70,7 +76,7 @@ export default function SearchResultsPage() { setPatients(payload) setLoading(false) } catch (err) { - if (axios.isAxiosError(err) && (err.response?.status === 401)) { + if (axios.isAxiosError(err) && err.response?.status === 401) { const invalidSessionCause = err.response?.data?.invalidSessionCause handleRestartLogin(auth, invalidSessionCause) return @@ -82,16 +88,16 @@ export default function SearchResultsPage() { } const handleRowClick = (nhsNumber: string) => { - searchContext.clearSearchParameters() - searchContext.setNhsNumber(nhsNumber) + // only preserve relevant search parameters and add NHS number + const relevantParams = + navigationContext.getRelevantSearchParameters("basicDetails") + searchContext.setAllSearchParameters({ + ...relevantParams, + nhsNumber: nhsNumber + }) navigate(`${FRONTEND_PATHS.PRESCRIPTION_LIST_CURRENT}`) } - // Pass back the query string to keep filled form on return - const handleGoBack = () => { - navigate(`${FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS}`) - } - // Sort by first name const sortedPatients = patients .toSorted((a, b) => (a.givenName?.[0] ?? "").localeCompare(b.givenName?.[0] ?? "")) @@ -118,7 +124,7 @@ export default function SearchResultsPage() { // Show not found message if no valid patients if (patients.length === 0) { - return + return } // Show too many results message if search returns too many patients @@ -127,72 +133,82 @@ export default function SearchResultsPage() { } return ( -
- - - - e.key === "Enter" && handleGoBack()} - > - {SearchResultsPageStrings.GO_BACK} - - - - - -

- {SearchResultsPageStrings.TITLE} -

-

- {SearchResultsPageStrings.RESULTS_COUNT.replace("{count}", sortedPatients.length.toString())} -

- - - - {SearchResultsPageStrings.TABLE.NAME} - {SearchResultsPageStrings.TABLE.GENDER} - {SearchResultsPageStrings.TABLE.DOB} - {SearchResultsPageStrings.TABLE.ADDRESS} - {SearchResultsPageStrings.TABLE.NHS_NUMBER} - - - - {sortedPatients.map((patient) => ( - handleRowClick(patient.nhsNumber)} - onKeyDown={e => (e.key === "Enter" || e.key === " ") && handleRowClick(patient.nhsNumber)} - > - - { - e.preventDefault() - handleRowClick(patient.nhsNumber) - }} - > - {patient.givenName?.[0] ?? ""} {patient.familyName} - - {`NHS number ${patient.nhsNumber.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3")}`} - - + + + + +
+ + +
+ + + +

+ {SearchResultsPageStrings.TITLE} +

+

+ {SearchResultsPageStrings.RESULTS_COUNT.replace("{count}", sortedPatients.length.toString())} +

+
+ + + + {SearchResultsPageStrings.TABLE.NAME} - {patient.gender} - {patient.dateOfBirth} - {patient.address?.join(", ")} - - {patient.nhsNumber.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3")} + + {SearchResultsPageStrings.TABLE.GENDER} + + + {SearchResultsPageStrings.TABLE.DOB} + + + {SearchResultsPageStrings.TABLE.ADDRESS} + + + {SearchResultsPageStrings.TABLE.NHS_NUMBER} - ))} - -
- -
-
-
+ + + {sortedPatients.map((patient) => ( + handleRowClick(patient.nhsNumber)} + onKeyDown={e => (e.key === "Enter" || e.key === " ") && handleRowClick(patient.nhsNumber)} + > + + { + e.preventDefault() + handleRowClick(patient.nhsNumber) + }} + > + {patient.givenName?.[0] ?? ""} {patient.familyName} + + {`NHS number ${patient.nhsNumber.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3")}`} + + + + {patient.gender} + {patient.dateOfBirth} + {patient.address?.join(", ")} + + {patient.nhsNumber.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3")} + + + ))} + + + + + +
+ ) } diff --git a/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx b/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx index 8886e724c2..abffee3496 100644 --- a/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx +++ b/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx @@ -1,12 +1,7 @@ import {useEffect, useState} from "react" -import {Link, useNavigate} from "react-router-dom" +import {useNavigate} from "react-router-dom" -import { - BackLink, - Col, - Container, - Row -} from "nhsuk-react-components" +import {Col, Container, Row} from "nhsuk-react-components" import { OrgSummary, @@ -25,11 +20,11 @@ import EpsSpinner from "@/components/EpsSpinner" import {SiteDetailsCards} from "@/components/prescriptionDetails/SiteDetailsCards" import {ItemsCards} from "@/components/prescriptionDetails/ItemsCards" import {MessageHistoryCard} from "@/components/prescriptionDetails/MessageHistoryCard" +import EpsBackLink from "@/components/EpsBackLink" import http from "@/helpers/axios" import {logger} from "@/helpers/logger" import {useSearchContext} from "@/context/SearchProvider" -import {buildBackLink, determineSearchType} from "@/helpers/prescriptionNotFoundLinks" import axios from "axios" import {handleRestartLogin} from "@/helpers/logout" import {useAuth} from "@/context/AuthProvider" @@ -50,9 +45,6 @@ export default function PrescriptionDetailsPage() { const searchContext = useSearchContext() const navigate = useNavigate() - const searchType = determineSearchType(searchContext) - const backLinkUrl = buildBackLink(searchType, searchContext) - const getPrescriptionDetails = async ( prescriptionId: string, prescriptionIssueNumber?: string | undefined @@ -105,7 +97,6 @@ export default function PrescriptionDetailsPage() { useEffect(() => { const runGetPrescriptionDetails = async () => { - const prescriptionId = searchContext.prescriptionId if (!prescriptionId) { logger.info("No prescriptionId provided - redirecting to search") @@ -124,15 +115,14 @@ export default function PrescriptionDetailsPage() { if (loading) { return ( -
+
-

- {STRINGS.HEADER} -

+

{STRINGS.HEADER}

{STRINGS.LOADING_FULL_PRESCRIPTION}

@@ -145,19 +135,13 @@ export default function PrescriptionDetailsPage() { } return ( -
- - - - - {STRINGS.GO_BACK} - - - + + +

{STRINGS.HEADER}

@@ -177,7 +161,7 @@ export default function PrescriptionDetailsPage() { {/* Message history timeline */}
- -
+
+
) } diff --git a/packages/cpt-ui/src/pages/PrescriptionListPage.tsx b/packages/cpt-ui/src/pages/PrescriptionListPage.tsx index bc915780b6..091a4e5d2a 100644 --- a/packages/cpt-ui/src/pages/PrescriptionListPage.tsx +++ b/packages/cpt-ui/src/pages/PrescriptionListPage.tsx @@ -1,11 +1,6 @@ import React, {Fragment, useEffect, useState} from "react" -import {Link, useNavigate} from "react-router-dom" -import { - BackLink, - Col, - Container, - Row -} from "nhsuk-react-components" +import {useNavigate} from "react-router-dom" +import {Col, Container, Row} from "nhsuk-react-components" import "../styles/PrescriptionTable.scss" import axios from "axios" @@ -17,6 +12,7 @@ import PrescriptionsListTabs from "@/components/prescriptionList/PrescriptionsLi import {TabHeader} from "@/components/EpsTabs" import PrescriptionNotFoundMessage from "@/components/PrescriptionNotFoundMessage" import UnknownErrorMessage from "@/components/UnknownErrorMessage" +import EpsBackLink from "@/components/EpsBackLink" import {PRESCRIPTION_LIST_TABS} from "@/constants/ui-strings/PrescriptionListTabStrings" import {PRESCRIPTION_LIST_PAGE_STRINGS} from "@/constants/ui-strings/PrescriptionListPageStrings" @@ -27,7 +23,6 @@ import {SearchResponse, PrescriptionSummary} from "@cpt-ui-common/common-types/s import http from "@/helpers/axios" import {logger} from "@/helpers/logger" import {useSearchContext} from "@/context/SearchProvider" -import {buildBackLink, determineSearchType} from "@/helpers/prescriptionNotFoundLinks" import {handleRestartLogin, signOut} from "@/helpers/logout" import {useAuth} from "@/context/AuthProvider" import {AUTH_CONFIG} from "@/constants/environment" @@ -45,9 +40,6 @@ export default function PrescriptionListPage() { const [showNotFound, setShowNotFound] = useState(false) const [error, setError] = useState(false) - const searchType = determineSearchType(searchContext) - const backLinkUrl = buildBackLink(searchType, searchContext) - const auth = useAuth() const navigate = useNavigate() @@ -59,10 +51,10 @@ export default function PrescriptionListPage() { const searchParams = new URLSearchParams() // determine which search page to go back to based on query parameters - if (searchContext.prescriptionId) { - searchParams.append("prescriptionId", searchContext.prescriptionId) - } else if (searchContext.nhsNumber) { + if (searchContext.nhsNumber) { searchParams.append("nhsNumber", searchContext.nhsNumber) + } else if (searchContext.prescriptionId) { + searchParams.append("prescriptionId", searchContext.prescriptionId) } else { logger.info("No search parameter provided - redirecting to prescription ID search") navigate(FRONTEND_PATHS.SEARCH_BY_PRESCRIPTION_ID) @@ -91,7 +83,10 @@ export default function PrescriptionListPage() { searchResults.pastPrescriptions.length === 0 && searchResults.futurePrescriptions.length === 0 ) { - logger.warn("A patient was returned, but they do not have any prescriptions.", searchResults) + logger.error( + "A patient was returned, but they do not have any prescriptions.", + searchResults + ) setPatientDetails(searchResults.patient) setShowNotFound(true) setLoading(false) @@ -182,22 +177,16 @@ export default function PrescriptionListPage() { return ( - {PRESCRIPTION_LIST_PAGE_STRINGS.PAGE_TITLE} + + {PRESCRIPTION_LIST_PAGE_STRINGS.PAGE_TITLE} + +
- - - - -