From e7c616f7acf50ba71be80011d6279355b500274a Mon Sep 17 00:00:00 2001 From: adamwhitingnhs Date: Fri, 13 Mar 2026 08:42:18 +0000 Subject: [PATCH] [PRMP-1482] Implement user restrictions list page --- .../UserPatientRestrictionsAddStage.test.tsx | 10 + .../UserPatientRestrictionsAddStage.tsx | 5 + .../UserPatientRestrictionsIndex.test.tsx | 12 +- .../UserPatientRestrictionsIndex.tsx | 22 +- .../UserPatientRestrictionsListStage.scss | 5 + .../UserPatientRestrictionsListStage.test.tsx | 328 +++++++++++++++ .../UserPatientRestrictionsListStage.tsx | 377 ++++++++++++++++++ .../UserPatientRestrictionsViewStage.test.tsx | 10 + .../UserPatientRestrictionsViewStage.tsx | 5 + .../generic/paginationV2/Pagination.scss | 3 +- .../createUserPatientRestriction.ts | 0 .../deleteUserPatientRestriction.ts | 0 .../getUserPatientRestrictions.test.ts | 60 +++ .../getUserPatientRestrictions.ts | 47 +++ app/src/helpers/test/testBuilders.ts | 47 +++ .../utils/formatSmartcardNumber.test.ts | 9 + .../helpers/utils/formatSmartcardNumber.ts | 5 + .../helpers/utils/nhsNumberValidator.test.ts | 33 ++ app/src/helpers/utils/nhsNumberValidator.ts | 22 + .../UserPatientRestrictionsPage.tsx | 21 +- app/src/router/AppRouter.tsx | 17 + app/src/styles/App.scss | 1 + app/src/types/generic/endpoints.ts | 3 + app/src/types/generic/inputRef.ts | 4 +- app/src/types/generic/routes.ts | 11 + app/src/types/generic/selectRef.ts | 4 +- app/src/types/generic/textareaRef.ts | 4 +- .../types/generic/userPatientRestriction.ts | 10 + 28 files changed, 1063 insertions(+), 12 deletions(-) create mode 100644 app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.test.tsx create mode 100644 app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.tsx create mode 100644 app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.scss create mode 100644 app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.test.tsx create mode 100644 app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.tsx create mode 100644 app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.test.tsx create mode 100644 app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.tsx create mode 100644 app/src/helpers/requests/userPatientRestrictions/createUserPatientRestriction.ts create mode 100644 app/src/helpers/requests/userPatientRestrictions/deleteUserPatientRestriction.ts create mode 100644 app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.test.ts create mode 100644 app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.ts create mode 100644 app/src/helpers/utils/formatSmartcardNumber.test.ts create mode 100644 app/src/helpers/utils/formatSmartcardNumber.ts create mode 100644 app/src/helpers/utils/nhsNumberValidator.test.ts create mode 100644 app/src/helpers/utils/nhsNumberValidator.ts create mode 100644 app/src/types/generic/userPatientRestriction.ts diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.test.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.test.tsx new file mode 100644 index 0000000000..930f5110b1 --- /dev/null +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react'; +import UserPatientRestrictionsAddStage from './UserPatientRestrictionsAddStage'; + +describe('UserPatientRestrictionsAddStage', () => { + it('renders correctly', () => { + render(); + + expect(screen.getByText('Add user patient restriction')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.tsx new file mode 100644 index 0000000000..bd26a1744d --- /dev/null +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage.tsx @@ -0,0 +1,5 @@ +const UserPatientRestrictionsAddStage = (): React.JSX.Element => { + return
Add user patient restriction
; +}; + +export default UserPatientRestrictionsAddStage; diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.test.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.test.tsx index b5a3b90269..9cac4a06c4 100644 --- a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.test.tsx +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import UserPatientRestrictionsIndex from './UserPatientRestrictionsIndex'; import { runAxeTest } from '../../../../helpers/test/axeTestHelper'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { routes } from '../../../../types/generic/routes'; +import { routeChildren, routes } from '../../../../types/generic/routes'; vi.mock('../../../helpers/hooks/useTitle'); vi.mock('../../../../styles/right-chevron-circle.svg', () => ({ @@ -59,6 +59,16 @@ describe('UserRestrictionsPage', (): void => { screen.getByTestId('user-restrictions-back-btn').click(); expect(mockNavigate).toHaveBeenCalledWith(routes.ADMIN_ROUTE); }); + + it('navigates to add restriction page when add restriction button is clicked', (): void => { + screen.getByTestId('add-user-restriction-btn').click(); + expect(mockNavigate).toHaveBeenCalledWith(routeChildren.USER_PATIENT_RESTRICTIONS_ADD); + }); + + it('navigates to view restrictions page when view restrictions button is clicked', (): void => { + screen.getByTestId('view-user-restrictions-btn').click(); + expect(mockNavigate).toHaveBeenCalledWith(routeChildren.USER_PATIENT_RESTRICTIONS_LIST); + }); }); describe('Accessibility', (): void => { diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.tsx index 6d70477187..e9f8addfe3 100644 --- a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.tsx +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex.tsx @@ -3,9 +3,11 @@ import { JSX } from 'react'; import useTitle from '../../../../helpers/hooks/useTitle'; import { ReactComponent as RightCircleIcon } from '../../../../styles/right-chevron-circle.svg'; import BackButton from '../../../generic/backButton/BackButton'; -import { routes } from '../../../../types/generic/routes'; +import { routeChildren, routes } from '../../../../types/generic/routes'; +import { useNavigate } from 'react-router-dom'; const UserPatientRestrictionsIndex = (): JSX.Element => { + const navigate = useNavigate(); useTitle({ pageTitle: 'Restrict staff from accessing patient records' }); return ( @@ -17,7 +19,14 @@ const UserPatientRestrictionsIndex = (): JSX.Element => { - + { + e.preventDefault(); + navigate(routeChildren.USER_PATIENT_RESTRICTIONS_ADD); + }} + > Add a restriction @@ -32,7 +41,14 @@ const UserPatientRestrictionsIndex = (): JSX.Element => { - + { + e.preventDefault(); + navigate(routeChildren.USER_PATIENT_RESTRICTIONS_LIST); + }} + > View and remove a restriction diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.scss b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.scss new file mode 100644 index 0000000000..e6bbb21063 --- /dev/null +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.scss @@ -0,0 +1,5 @@ +.user-patient-restrictions-list-stage { + #search-input { + width: 300px; + } +} \ No newline at end of file diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.test.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.test.tsx new file mode 100644 index 0000000000..8ce7c7042b --- /dev/null +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.test.tsx @@ -0,0 +1,328 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import UserPatientRestrictionsListStage from './UserPatientRestrictionsListStage'; +import userEvent from '@testing-library/user-event'; +import { Mock } from 'vitest'; +import { routeChildren } from '../../../../types/generic/routes'; +import getUserPatientRestrictions from '../../../../helpers/requests/userPatientRestrictions/getUserPatientRestrictions'; +import { buildUserRestrictions } from '../../../../helpers/test/testBuilders'; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: (): Mock => mockNavigate, + Link: ({ + children, + onClick, + 'data-testid': dataTestId, + }: { + children: React.ReactNode; + onClick?: () => void; + 'data-testid'?: string; + }): React.JSX.Element => ( +
+ {children} +
+ ), + }; +}); +vi.mock('../../../../helpers/requests/userPatientRestrictions/getUserPatientRestrictions'); +vi.mock('../../../../helpers/hooks/useBaseAPIHeaders'); +vi.mock('../../../../helpers/hooks/useBaseAPIUrl'); + +const mockNavigate = vi.fn(); +const mockGetUserPatientRestrictions = getUserPatientRestrictions as Mock; + +describe('UserPatientRestrictionsListStage', () => { + beforeEach(() => { + mockGetUserPatientRestrictions.mockResolvedValue({ + restrictions: buildUserRestrictions(), + }); + }); + + it('renders correctly', () => { + render(); + + expect( + screen.getByText('Manage restrictions on access to patient records'), + ).toBeInTheDocument(); + }); + + it('should navigate to add restrictions stage when add restriction button is clicked', async () => { + render(); + + const addRestrictionButton = screen.getByRole('button', { name: 'Add a restriction' }); + expect(addRestrictionButton).toBeInTheDocument(); + + await userEvent.click(addRestrictionButton); + + expect(mockNavigate).toHaveBeenCalledWith(routeChildren.USER_PATIENT_RESTRICTIONS_ADD); + }); + + it('should navigate to view restrictions stage when view restriction button is clicked', async () => { + const restrictions = buildUserRestrictions(); + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + }); + + render(); + + await waitFor(async () => { + const viewRestrictionButton = screen.getByTestId( + `view-record-link-${restrictions[0].id}`, + ); + expect(viewRestrictionButton).toBeInTheDocument(); + + await userEvent.click(viewRestrictionButton); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + routeChildren.USER_PATIENT_RESTRICTIONS_VIEW.replace( + ':restrictionId', + restrictions[0].id, + ), + ); + }); + + it('should show error message when restrictions fail to load', async () => { + mockGetUserPatientRestrictions.mockRejectedValueOnce( + new Error('Failed to load restrictions'), + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('failed-to-load-error')).toBeInTheDocument(); + }); + }); + + it('should show loading state when restrictions are loading', async () => { + mockGetUserPatientRestrictions.mockReturnValueOnce( + new Promise(() => { + // never resolves to simulate loading state + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Searching...')).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + }); + + it('should show no restrictions message when there are no restrictions', async () => { + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('No user patient restrictions found')).toBeInTheDocument(); + }); + }); + + it('should show pagination when there are more than 10 restrictions', async () => { + const restrictions = buildUserRestrictions(); + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + }); + + it('should load next page of restrictions when next button is clicked', async () => { + const restrictions = buildUserRestrictions(); + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token', + }); + + render(); + + await waitFor(async () => { + const nextButton = screen.getByTestId('next-page-link'); + expect(nextButton).toBeInTheDocument(); + await userEvent.click(nextButton); + }); + + await waitFor(() => { + expect(mockGetUserPatientRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + pageToken: 'next-page-token', + }), + ); + }); + }); + + it('should show previous button when on next page of restrictions', async () => { + const restrictions = buildUserRestrictions(); + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token-2', + }); + + await waitFor(async () => { + const nextButton = screen.getByText('Next'); + expect(nextButton).toBeInTheDocument(); + await userEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText('Previous')).toBeInTheDocument(); + }); + }); + + it('should load previous page of restrictions when previous button is clicked', async () => { + const restrictions = buildUserRestrictions(); + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token', + }); + + render(); + + await waitFor(async () => { + const nextButton = screen.getByText('Next'); + expect(nextButton).toBeInTheDocument(); + await userEvent.click(nextButton); + }); + + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token-2', + }); + + await waitFor(async () => { + const previousButton = screen.getByText('Previous'); + expect(previousButton).toBeInTheDocument(); + await userEvent.click(previousButton); + }); + + expect(mockGetUserPatientRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + pageToken: undefined, // should go back to first page + }), + ); + }); + + it('should load selected page of restrictions when pagination buttons are clicked', async () => { + const restrictions = buildUserRestrictions(); + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token', + }); + + render(); + + await waitFor(async () => { + const nextButton = screen.getByText('Next'); + expect(nextButton).toBeInTheDocument(); + await userEvent.click(nextButton); + }); + + mockGetUserPatientRestrictions.mockResolvedValueOnce({ + restrictions, + nextPageToken: 'next-page-token-2', + }); + + await waitFor(async () => { + const page1Button = screen.getByRole('link', { name: 'Page 1' }); + expect(page1Button).toBeInTheDocument(); + await userEvent.click(page1Button); + }); + + expect(mockGetUserPatientRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + pageToken: undefined, // should load first page + }), + ); + }); + + it('should send nhsNumber parameter when searching by valid nhs number', async () => { + render(); + + const nhsNumber = '2222222222'; + await userEvent.click(screen.getByTestId('nhs-number-radio-button')); + await userEvent.type(screen.getByTestId('search-input'), nhsNumber); + await userEvent.click(screen.getByRole('button', { name: 'Search' })); + + await waitFor(() => { + expect(mockGetUserPatientRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + nhsNumber, + smartcardNumber: undefined, + }), + ); + }); + }); + + it('should send smartcardNumber parameter when searching by valid smartcard number', async () => { + render(); + + const smartcardNumber = '123456789012'; + await userEvent.click(screen.getByTestId('smartcard-number-radio-button')); + await userEvent.type(screen.getByTestId('search-input'), smartcardNumber); + await userEvent.click(screen.getByTestId('search-button')); + + await waitFor(() => { + expect(mockGetUserPatientRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + nhsNumber: undefined, + smartcardNumber, + }), + ); + }); + }); + + it.each(['123', 'abc', '123456789', '12345678901', '1234567890'])( + 'should show validation error when searching by invalid nhs number: %s', + async (invalidNhsNumber) => { + render(); + + await userEvent.click(screen.getByTestId('nhs-number-radio-button')); + await userEvent.type(screen.getByTestId('search-input'), invalidNhsNumber); + await userEvent.click(screen.getByRole('button', { name: 'Search' })); + + await waitFor(() => { + expect( + screen.getByText('Please enter a valid 10-digit NHS number'), + ).toBeInTheDocument(); + }); + }, + ); + + it.each(['123', 'abc', '123456789', '12345678901', '1234567890', '12345678901a'])( + 'should show validation error when searching by invalid smartcard number: %s', + async (invalidSmartcardNumber) => { + render(); + + await userEvent.click(screen.getByTestId('smartcard-number-radio-button')); + await userEvent.type(screen.getByTestId('search-input'), invalidSmartcardNumber); + await userEvent.click(screen.getByRole('button', { name: 'Search' })); + + await waitFor(() => { + expect( + screen.getByText('Please enter a valid 12-digit NHS smartcard number'), + ).toBeInTheDocument(); + }); + }, + ); +}); diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.tsx new file mode 100644 index 0000000000..92654dc585 --- /dev/null +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.tsx @@ -0,0 +1,377 @@ +import { Link, useNavigate } from 'react-router-dom'; +import useTitle from '../../../../helpers/hooks/useTitle'; +import BackButton from '../../../generic/backButton/BackButton'; +import { routeChildren } from '../../../../types/generic/routes'; +import { Button, ErrorMessage, Fieldset, Radios, Table, TextInput } from 'nhsuk-react-components'; +import { useForm } from 'react-hook-form'; +import { InputRef } from '../../../../types/generic/inputRef'; +import { useEffect, useRef, useState } from 'react'; +import SpinnerButton from '../../../generic/spinnerButton/SpinnerButton'; +import { UserPatientRestriction } from '../../../../types/generic/userPatientRestriction'; +import { Pagination } from '../../../generic/paginationV2/Pagination'; +import SpinnerV2 from '../../../generic/spinnerV2/SpinnerV2'; +import { formatNhsNumber } from '../../../../helpers/utils/formatNhsNumber'; +import { getFormattedDateFromString } from '../../../../helpers/utils/formatDate'; +import getUserPatientRestrictions, { + GetUserPatientRestrictionsResponse, +} from '../../../../helpers/requests/userPatientRestrictions/getUserPatientRestrictions'; +import useBaseAPIUrl from '../../../../helpers/hooks/useBaseAPIUrl'; +import useBaseAPIHeaders from '../../../../helpers/hooks/useBaseAPIHeaders'; +import validateNhsNumber from '../../../../helpers/utils/nhsNumberValidator'; +import { isMock } from '../../../../helpers/utils/isLocal'; +import { AxiosError } from 'axios'; +import { buildUserRestrictions } from '../../../../helpers/test/testBuilders'; +import { getFormattedPatientFullName } from '../../../../helpers/utils/formatPatientFullName'; +import { PatientDetails } from '../../../../types/generic/patientDetails'; +import formatSmartcardNumber from '../../../../helpers/utils/formatSmartcardNumber'; + +enum Fields { + searchType = 'searchType', + searchText = 'searchText', +} + +enum SearchTypeOptions { + NHS_NUMBER = 'nhsNumber', + SMARTCARD_NUMBER = 'smartcardNumber', +} + +type FormData = { + [Fields.searchType]: string; + [Fields.searchText]: string; +}; + +const UserPatientRestrictionsListStage = (): React.JSX.Element => { + const navigate = useNavigate(); + const pageTitle = 'Manage restrictions on access to patient records'; + useTitle({ pageTitle }); + const baseAPIUrl = useBaseAPIUrl(); + const baseAPIHeaders = useBaseAPIHeaders(); + + const mounted = useRef(false); + const [isLoading, setIsLoading] = useState(false); + const [failedLoading, setFailedLoading] = useState(false); + const [restrictions, setRestrictions] = useState([]); + const [nextPageToken, setNextPageToken] = useState(undefined); + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [pageTokens, setPageTokens] = useState(['']); + + const { + handleSubmit, + register, + getValues, + formState: { errors }, + } = useForm({ + defaultValues: { + [Fields.searchType]: SearchTypeOptions.NHS_NUMBER, + }, + }); + + const { ref: radioRef, ...radioProps } = register(Fields.searchType); + const { ref: inputRef, ...inputProps } = register(Fields.searchText, { + validate: (value: string) => { + if (!value) { + return true; + } + + const searchType = getValues(Fields.searchType); + if (searchType === SearchTypeOptions.NHS_NUMBER) { + return validateNhsNumber(value) || 'Please enter a valid 10-digit NHS number'; + } + + if (searchType === SearchTypeOptions.SMARTCARD_NUMBER) { + return ( + /^[\d]{12}$/.test(value) || 'Please enter a valid 12-digit NHS smartcard number' + ); + } + }, + }); + + const loadRestrictions = async (pageIndex: number): Promise => { + setIsLoading(true); + setFailedLoading(false); + + try { + const searchText = getValues(Fields.searchText); + const searchType = getValues(Fields.searchType); + const response = await getUserPatientRestrictions({ + nhsNumber: searchType === SearchTypeOptions.NHS_NUMBER ? searchText : undefined, + smartcardNumber: + searchType === SearchTypeOptions.SMARTCARD_NUMBER ? searchText : undefined, + baseAPIUrl, + baseAPIHeaders, + limit: 10, + pageToken: pageIndex > 0 ? pageTokens[pageIndex] : undefined, + }); + + handleSuccess(response, pageIndex); + } catch (e) { + const error = e as AxiosError; + if (isMock(error)) { + handleSuccess( + { + restrictions: buildUserRestrictions(), + nextPageToken: `mock-next-page-token-${pageIndex}`, + }, + pageIndex, + ); + } else { + setFailedLoading(true); + } + } finally { + setIsLoading(false); + } + }; + + const handleSuccess = ( + response: GetUserPatientRestrictionsResponse, + pageIndex: number, + ): void => { + setRestrictions(response.restrictions); + setNextPageToken(response.nextPageToken); + setCurrentPageIndex(pageIndex); + + if (response.nextPageToken && !pageTokens.includes(response.nextPageToken)) { + setPageTokens((prev) => { + const newTokens = [...prev]; + newTokens[pageIndex + 1] = response.nextPageToken!; + return newTokens; + }); + } + }; + + useEffect(() => { + if (!mounted.current) { + mounted.current = true; + loadRestrictions(0); + } + }, [mounted.current]); + + const handleSearch = async (): Promise => { + setRestrictions([]); + setNextPageToken(undefined); + setCurrentPageIndex(0); + setPageTokens(['']); + mounted.current = false; + }; + + return ( +
+ + +

{pageTitle}

+ +

You cannot view or remove a restriction on your own NHS smartcard number.

+ + + + +
+
+
+ + + Patient's NHS number + + + NHS smartcard number + + +
+ +
+ + {isLoading ? ( + + ) : ( + + )} +
+
+
+ + + + + Patient's NHS number + Patient's name + NHS smartcard number + Staff member + Date restriction added + View + + + + + +
+ + {/* previous link */} + {currentPageIndex > 0 && ( + { + e.preventDefault(); + loadRestrictions(currentPageIndex - 1); + }} + previous + /> + )} + {/* previous page items */} + {pageTokens.map((token, index) => { + return ( + { + e.preventDefault(); + loadRestrictions(index); + }} + number={index + 1} + /> + ); + })} + {/* next link */} + {nextPageToken && ( + { + e.preventDefault(); + loadRestrictions(currentPageIndex + 1); + }} + next + /> + )} + +
+
+ ); +}; + +export default UserPatientRestrictionsListStage; + +type TableRowsProps = { + restrictions: UserPatientRestriction[]; + isLoading: boolean; + failedLoading: boolean; +}; +const TableRows = ({ + restrictions, + isLoading, + failedLoading, +}: TableRowsProps): React.JSX.Element => { + const navigate = useNavigate(); + + if (failedLoading) { + return ( + + + + Failed to load user patient restrictions + + + + ); + } + + if (restrictions.length > 0 && !isLoading) { + return ( + <> + {restrictions.map((restriction): React.JSX.Element => { + return ( + + {formatNhsNumber(restriction.nhsNumber)} + + {getFormattedPatientFullName({ + givenName: restriction.patientGivenName, + familyName: restriction.patientFamilyName, + } as PatientDetails)} + + + {formatSmartcardNumber(restriction.restrictedUser)} + + {`${restriction.restrictedUserFirstName} ${restriction.restrictedUserLastName}`} + + {getFormattedDateFromString(`${restriction.created}`)} + + + { + e.preventDefault(); + navigate( + routeChildren.USER_PATIENT_RESTRICTIONS_VIEW.replace( + ':restrictionId', + restriction.id, + ), + ); + }} + > + View + + + + ); + })} + + ); + } + + return ( + + + {isLoading ? ( + + ) : ( + <>No user patient restrictions found + )} + + + ); +}; diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.test.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.test.tsx new file mode 100644 index 0000000000..f2aa2de3bb --- /dev/null +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react'; +import UserPatientRestrictionsViewStage from './UserPatientRestrictionsViewStage'; + +describe('UserPatientRestrictionsViewStage', () => { + it('renders correctly', () => { + render(); + + expect(screen.getByText('viewing user patient restrictions')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.tsx b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.tsx new file mode 100644 index 0000000000..545664632f --- /dev/null +++ b/app/src/components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage.tsx @@ -0,0 +1,5 @@ +const UserPatientRestrictionsViewStage = (): React.JSX.Element => { + return
viewing user patient restrictions
; +}; + +export default UserPatientRestrictionsViewStage; diff --git a/app/src/components/generic/paginationV2/Pagination.scss b/app/src/components/generic/paginationV2/Pagination.scss index 09d4f6f9f1..beab07fe9f 100644 --- a/app/src/components/generic/paginationV2/Pagination.scss +++ b/app/src/components/generic/paginationV2/Pagination.scss @@ -1,4 +1,5 @@ -.reviews-page { +.reviews-page, +.user-patient-restrictions-list-stage { .nhsuk-pagination { margin-top: 40px a { cursor: pointer; diff --git a/app/src/helpers/requests/userPatientRestrictions/createUserPatientRestriction.ts b/app/src/helpers/requests/userPatientRestrictions/createUserPatientRestriction.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/src/helpers/requests/userPatientRestrictions/deleteUserPatientRestriction.ts b/app/src/helpers/requests/userPatientRestrictions/deleteUserPatientRestriction.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.test.ts b/app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.test.ts new file mode 100644 index 0000000000..aa32218b82 --- /dev/null +++ b/app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.test.ts @@ -0,0 +1,60 @@ +import getUserPatientRestrictions from './getUserPatientRestrictions'; + +vi.mock('axios', () => ({ + default: { + get: mockAxiosGet, + }, +})); +const mockAxiosGet = vi.hoisted(() => vi.fn()); + +const baseAPIUrl = 'https://example.com/api'; +const baseAPIHeaders = { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', +}; + +describe('getUserPatientRestrictions', () => { + beforeEach(() => { + mockAxiosGet.mockClear(); + }); + + it('should fetch patient restrictions successfully', async () => { + const mockData = [ + { id: 1, type: 'Restriction A' }, + { id: 2, type: 'Restriction B' }, + ]; + + mockAxiosGet.mockResolvedValueOnce({ data: mockData }); + + const result = await getUserPatientRestrictions({ + nhsNumber: '1234567890', + smartcardNumber: '9876543210', + baseAPIUrl, + baseAPIHeaders, + }); + + expect(mockAxiosGet).toHaveBeenCalledWith(`${baseAPIUrl}/UserRestrictions`, { + headers: baseAPIHeaders, + params: { + nhsNumber: '1234567890', + smartcardId: '9876543210', + limit: 10, + }, + }); + expect(result).toEqual(mockData); + }); + + it('should handle errors when fetching patient restrictions', async () => { + const mockError = new Error('Network error'); + mockAxiosGet.mockRejectedValueOnce(mockError); + + await expect( + getUserPatientRestrictions({ + nhsNumber: '1234567890', + smartcardNumber: '9876543210', + baseAPIUrl, + baseAPIHeaders, + }), + ).rejects.toThrow('Network error'); + }); +}); diff --git a/app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.ts b/app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.ts new file mode 100644 index 0000000000..f3de937ace --- /dev/null +++ b/app/src/helpers/requests/userPatientRestrictions/getUserPatientRestrictions.ts @@ -0,0 +1,47 @@ +import axios, { AxiosError } from 'axios'; +import { AuthHeaders } from '../../../types/blocks/authHeaders'; +import { UserPatientRestriction } from '../../../types/generic/userPatientRestriction'; +import { endpoints } from '../../../types/generic/endpoints'; + +export type GetUserPatientRestrictionsArgs = { + nhsNumber?: string; + smartcardNumber?: string; + baseAPIUrl: string; + baseAPIHeaders: AuthHeaders; + limit?: number; + pageToken?: string; +}; + +export type GetUserPatientRestrictionsResponse = { + restrictions: UserPatientRestriction[]; + nextPageToken?: string; +}; + +const getUserPatientRestrictions = async ({ + nhsNumber, + smartcardNumber, + baseAPIUrl, + baseAPIHeaders, + limit = 10, + pageToken, +}: GetUserPatientRestrictionsArgs): Promise => { + try { + const url = baseAPIUrl + endpoints.USER_PATIENT_RESTRICTIONS; + const { data } = await axios.get(url, { + headers: baseAPIHeaders, + params: { + nhsNumber, + smartcardId: smartcardNumber, + limit, + nextPageToken: pageToken, + }, + }); + + return data; + } catch (e) { + const error = e as AxiosError; + throw error; + } +}; + +export default getUserPatientRestrictions; diff --git a/app/src/helpers/test/testBuilders.ts b/app/src/helpers/test/testBuilders.ts index 947cca3c9b..91b81c4041 100644 --- a/app/src/helpers/test/testBuilders.ts +++ b/app/src/helpers/test/testBuilders.ts @@ -21,6 +21,7 @@ import { import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../utils/documentType'; import { ReviewsResponse } from '../../types/generic/reviews'; import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; +import { UserPatientRestriction } from '../../types/generic/userPatientRestriction'; const buildUserAuth = (userAuthOverride?: Partial): UserAuth => { const auth: UserAuth = { @@ -253,6 +254,51 @@ const buildDocumentReference = (override: Partial = {}): Docu }; }; +const buildUserRestrictions = (patientCount?: number): UserPatientRestriction[] => { + return [ + { + id: 'restriction-123', + nhsNumber: '9000000009', + patientGivenName: ['John'], + patientFamilyName: 'Doe', + restrictedUser: '123456789012', + restrictedUserFirstName: 'John', + restrictedUserLastName: 'Smith', + created: '2024-01-01T12:00:00Z', + }, + { + id: 'restriction-456', + nhsNumber: '9000000009', + patientGivenName: ['John'], + patientFamilyName: 'Doe', + restrictedUser: '123456789012', + restrictedUserFirstName: 'Chuck', + restrictedUserLastName: 'Norris', + created: '2024-01-01T12:00:00Z', + }, + { + id: 'restriction-789', + nhsNumber: '9000000009', + patientGivenName: ['John'], + patientFamilyName: 'Doe', + restrictedUser: '123456789012', + restrictedUserFirstName: 'Barry', + restrictedUserLastName: 'Allen', + created: '2024-01-01T12:00:00Z', + }, + { + id: 'restriction-101', + nhsNumber: '9000000009', + patientGivenName: ['John'], + patientFamilyName: 'Doe', + restrictedUser: '123456789012', + restrictedUserFirstName: 'Tom', + restrictedUserLastName: 'Jones', + created: '2024-01-01T12:00:00Z', + }, + ]; +}; + export { buildPatientDetails, buildTextFile, @@ -268,4 +314,5 @@ export { buildMockUploadSession, buildMockReviewResponse, buildDocumentReference, + buildUserRestrictions, }; diff --git a/app/src/helpers/utils/formatSmartcardNumber.test.ts b/app/src/helpers/utils/formatSmartcardNumber.test.ts new file mode 100644 index 0000000000..15eb6c0488 --- /dev/null +++ b/app/src/helpers/utils/formatSmartcardNumber.test.ts @@ -0,0 +1,9 @@ +import formatSmartcardNumber from './formatSmartcardNumber'; + +describe('formatSmartcardNumber', () => { + it('should format a 12-digit smartcard number correctly', () => { + const input = '123456789012'; + const expectedOutput = '1234 5678 9012'; + expect(formatSmartcardNumber(input)).toBe(expectedOutput); + }); +}); diff --git a/app/src/helpers/utils/formatSmartcardNumber.ts b/app/src/helpers/utils/formatSmartcardNumber.ts new file mode 100644 index 0000000000..1f036191df --- /dev/null +++ b/app/src/helpers/utils/formatSmartcardNumber.ts @@ -0,0 +1,5 @@ +const formatSmartcardNumber = (input: string): string => { + return input.substring(0, 4) + ' ' + input.substring(4, 8) + ' ' + input.substring(8, 12); +}; + +export default formatSmartcardNumber; diff --git a/app/src/helpers/utils/nhsNumberValidator.test.ts b/app/src/helpers/utils/nhsNumberValidator.test.ts new file mode 100644 index 0000000000..35bfeffb2b --- /dev/null +++ b/app/src/helpers/utils/nhsNumberValidator.test.ts @@ -0,0 +1,33 @@ +import validateNhsNumber from './nhsNumberValidator'; + +describe('validateNhsNumber', () => { + const validNhsNumbers = [ + '2234567890', + '223 456 7890', + '223-456-7890', + ' 2234567890 ', + ' 223 456 7890 ', + ' 223-456-7890 ', + '2222222222', + ]; + + const invalidNhsNumbers = [ + '2234567891', + '2222222220', // Invalid check digit + '123456789', // Too short + '12345678901', // Too long + '1234 567890', // Incorrect spacing + '123-45-67890', // Incorrect dashes + 'abcdefghij', // Non-numeric characters + '', // Empty string + ' ', // Only whitespace + ]; + + it.each(validNhsNumbers)(`should return true for valid NHS number: "%s"`, (nhsNumber) => { + expect(validateNhsNumber(nhsNumber)).toBe(true); + }); + + it.each(invalidNhsNumbers)(`should return false for invalid NHS number: "%s"`, (nhsNumber) => { + expect(validateNhsNumber(nhsNumber)).toBe(false); + }); +}); diff --git a/app/src/helpers/utils/nhsNumberValidator.ts b/app/src/helpers/utils/nhsNumberValidator.ts new file mode 100644 index 0000000000..250961098c --- /dev/null +++ b/app/src/helpers/utils/nhsNumberValidator.ts @@ -0,0 +1,22 @@ +const validateNhsNumber = (value: string): boolean | string => { + if ( + !/^\s*([\d]{10})\s*$/.test(value) && // 1234567890 + !/^\s*([\d]{3}\s[\d]{3}\s[\d]{4})\s*$/.test(value) && // 123 456 7890 + !/^\s*([\d]{3}-[\d]{3}-[\d]{4})\s*$/.test(value) // 123-456-7890 + ) { + return false; + } + + const digitsOnlyValue = value.replaceAll(/\s|-/g, ''); + let modulus = 0; + for (let i = 0; i < 9; i++) { + modulus += Number(digitsOnlyValue.charAt(i)) * (10 - i); + } + + const expectedCheckChar = 11 - (modulus % 11); + + const checkChar = Number(digitsOnlyValue.charAt(9)); + return expectedCheckChar === 11 ? checkChar === 0 : expectedCheckChar === checkChar; +}; + +export default validateNhsNumber; diff --git a/app/src/pages/userPatientRestrictionsPage/UserPatientRestrictionsPage.tsx b/app/src/pages/userPatientRestrictionsPage/UserPatientRestrictionsPage.tsx index 53c79c8aec..fa087dd9bf 100644 --- a/app/src/pages/userPatientRestrictionsPage/UserPatientRestrictionsPage.tsx +++ b/app/src/pages/userPatientRestrictionsPage/UserPatientRestrictionsPage.tsx @@ -1,8 +1,12 @@ import { JSX, useEffect } from 'react'; -import { routes } from '../../types/generic/routes'; +import { routeChildren, routes } from '../../types/generic/routes'; import useConfig from '../../helpers/hooks/useConfig'; import { Outlet, Route, Routes, useNavigate } from 'react-router-dom'; import UserPatientRestrictionsIndex from '../../components/blocks/_userPatientRestrictions/userPatientRestrictionsIndex/UserPatientRestrictionsIndex'; +import { getLastURLPath } from '../../helpers/utils/urlManipulations'; +import UserPatientRestrictionsListStage from '../../components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage'; +import UserPatientRestrictionsViewStage from '../../components/blocks/_userPatientRestrictions/userPatientRestrictionsViewStage/UserPatientRestrictionsViewStage'; +import UserPatientRestrictionsAddStage from '../../components/blocks/_userPatientRestrictions/userPatientRestrictionsAddStage/UserPatientRestrictionsAddStage'; const UserPatientRestrictionsPage = (): JSX.Element => { const config = useConfig(); @@ -22,6 +26,21 @@ const UserPatientRestrictionsPage = (): JSX.Element => { <> } /> + + } + /> + + } + /> + + } + /> diff --git a/app/src/router/AppRouter.tsx b/app/src/router/AppRouter.tsx index 7e7a12e2f6..786d64c206 100644 --- a/app/src/router/AppRouter.tsx +++ b/app/src/router/AppRouter.tsx @@ -70,6 +70,7 @@ const { DOCUMENT_REASSIGN_PAGES, DOCUMENT_REASSIGN_PAGES_WILDCARD, USER_PATIENT_RESTRICTIONS, + USER_PATIENT_RESTRICTIONS_WILDCARD, } = routes; type Routes = { @@ -261,6 +262,18 @@ export const childRoutes = [ route: routeChildren.DOCUMENT_REASSIGN_COMPLETE, parent: DOCUMENT_REASSIGN_PAGES, }, + { + route: routeChildren.USER_PATIENT_RESTRICTIONS_ADD, + parent: USER_PATIENT_RESTRICTIONS, + }, + { + route: routeChildren.USER_PATIENT_RESTRICTIONS_VIEW, + parent: USER_PATIENT_RESTRICTIONS, + }, + { + route: routeChildren.USER_PATIENT_RESTRICTIONS_LIST, + parent: USER_PATIENT_RESTRICTIONS, + }, ]; export const routeMap: Routes = { @@ -418,6 +431,10 @@ export const routeMap: Routes = { page: , type: ROUTE_TYPE.PRIVATE, }, + [USER_PATIENT_RESTRICTIONS_WILDCARD]: { + page: , + type: ROUTE_TYPE.PRIVATE, + }, }; const createRoutesFromType = (routeType: ROUTE_TYPE): Array => diff --git a/app/src/styles/App.scss b/app/src/styles/App.scss index e2237639a8..4cab5b1efe 100644 --- a/app/src/styles/App.scss +++ b/app/src/styles/App.scss @@ -1302,3 +1302,4 @@ progress:not(.continuous-progress-bar) { @import '../components/blocks/_documentManagement/documentSelectStage/DocumentSelectStage.scss'; @import '../components/blocks/_patientDocuments/documentView/DocumentView.scss'; @import '../components/blocks/_documentManagement/documentSelectOrderStage/DocumentSelectOrderStage.scss'; +@import '../components/blocks/_userPatientRestrictions/userPatientRestrictionsListStage/UserPatientRestrictionsListStage.scss'; diff --git a/app/src/types/generic/endpoints.ts b/app/src/types/generic/endpoints.ts index 3e769c72db..2c7ee499cd 100644 --- a/app/src/types/generic/endpoints.ts +++ b/app/src/types/generic/endpoints.ts @@ -21,4 +21,7 @@ export enum endpoints { MOCK_LOGIN = 'Auth/MockLogin', DOCUMENT_REVIEW = '/DocumentReview', + + USER_PATIENT_RESTRICTIONS = '/UserRestrictions', + USER_PATIENT_RESTRICTIONS_WILDCARD = '/UserRestrictions/*', } diff --git a/app/src/types/generic/inputRef.ts b/app/src/types/generic/inputRef.ts index 333960a9f2..8525f8ee2d 100644 --- a/app/src/types/generic/inputRef.ts +++ b/app/src/types/generic/inputRef.ts @@ -1,4 +1,4 @@ -import type { MutableRefObject } from 'react'; +import type { RefObject } from 'react'; import type { RefCallBack } from 'react-hook-form'; -export interface InputRef extends MutableRefObject, RefCallBack {} +export interface InputRef extends RefObject, RefCallBack {} diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index 8c8b8454e6..1a93df797f 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -43,6 +43,7 @@ export enum routes { COOKIES_POLICY_WILDCARD = '/cookies-policy/*', USER_PATIENT_RESTRICTIONS = '/user-patient-restrictions', + USER_PATIENT_RESTRICTIONS_WILDCARD = '/user-patient-restrictions/*', } export enum routeChildren { @@ -101,6 +102,16 @@ export enum routeChildren { REVIEW_FILE_ERRORS = '/reviews/:reviewId/file-errors', COOKIES_POLICY_UPDATED = '/cookies-policy/confirmation', + + USER_PATIENT_RESTRICTIONS_ADD = '/user-patient-restrictions/add', + USER_PATIENT_RESTRICTIONS_CONFIRM_ADD = '/user-patient-restrictions/confirm-add', + USER_PATIENT_RESTRICTIONS_VIEW = '/user-patient-restrictions/:restrictionId', + USER_PATIENT_RESTRICTIONS_LIST = '/user-patient-restrictions/list', + USER_PATIENT_RESTRICTIONS_VERIFY_PATIENT = '/user-patient-restrictions/verify-patient', + USER_PATIENT_RESTRICTIONS_VERIFY_STAFF = '/user-patient-restrictions/verify-staff', + USER_PATIENT_RESTRICTIONS_RESTRICTED = '/user-patient-restrictions/restricted', + USER_PATIENT_RESTRICTIONS_REMOVE_COMPLETE = '/user-patient-restrictions/remove-complete', + USER_PATIENT_RESTRICTIONS_CANCEL = '/user-patient-restrictions/add-cancel', } export const navigateUrlParam = ( diff --git a/app/src/types/generic/selectRef.ts b/app/src/types/generic/selectRef.ts index 190a1bfada..7459581311 100644 --- a/app/src/types/generic/selectRef.ts +++ b/app/src/types/generic/selectRef.ts @@ -1,4 +1,4 @@ -import type { MutableRefObject } from 'react'; +import type { RefObject } from 'react'; import type { RefCallBack } from 'react-hook-form'; -export interface SelectRef extends MutableRefObject, RefCallBack {} +export interface SelectRef extends RefObject, RefCallBack {} diff --git a/app/src/types/generic/textareaRef.ts b/app/src/types/generic/textareaRef.ts index c9db9b70c5..e56ff8a4ef 100644 --- a/app/src/types/generic/textareaRef.ts +++ b/app/src/types/generic/textareaRef.ts @@ -1,4 +1,4 @@ -import type { MutableRefObject } from 'react'; +import type { RefObject } from 'react'; import type { RefCallBack } from 'react-hook-form'; -export interface TextAreaRef extends MutableRefObject, RefCallBack {} +export interface TextAreaRef extends RefObject, RefCallBack {} diff --git a/app/src/types/generic/userPatientRestriction.ts b/app/src/types/generic/userPatientRestriction.ts new file mode 100644 index 0000000000..30c26482a4 --- /dev/null +++ b/app/src/types/generic/userPatientRestriction.ts @@ -0,0 +1,10 @@ +export type UserPatientRestriction = { + id: string; + restrictedUser: string; + nhsNumber: string; + patientGivenName: string[]; + patientFamilyName: string; + restrictedUserFirstName: string; + restrictedUserLastName: string; + created: string; +};