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
+ 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;
+};