-
Upload complete
-
- {journey === 'update' && (
-
- You have successfully added additional files to the digital Lloyd George
- record for:
-
- )}
- {journey === 'new' && (
-
You have successfully uploaded a digital Lloyd George record for:
- )}
-
+
+ {pageTitle}
+
Patient name: {patientName}
- NHS Number: {formattedNhsNumber}
+ NHS number: {formattedNhsNumber}
Date of birth: {dob}
-
What happens next
+ {failedDocuments.length > 0 && (
+
+
Some of your files failed to upload
+
+ {showFiles && (
+
+ {failedDocuments.map((doc) => (
+
+ {doc.file.name}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
What happens next
{journey === 'update' && (
- You can now view the updated Lloyd George record for this patient in this
- service by{' '}
+ You can now view the updated {documentConfig.displayName} for this patient in
+ this service by{' '}
{
@@ -89,11 +131,9 @@ const DocumentUploadCompleteStage = ({ documents }: Props): React.JSX.Element =>
england.prmteam@nhs.net.
-
- You can add a note to the patient's electronic health record to say their Lloyd
- George record is stored in this service. Use SNOMED code 'Lloyd George record
- folder' 16521000000101.
-
+ {documentConfig.content.uploadFilesExtraParagraph && (
+
{documentConfig.content.uploadFilesExtraParagraph}
+ )}
For information on destroying your paper records and removing the digital files from
diff --git a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx
index 27797cbc4..4a037258b 100644
--- a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx
+++ b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx
@@ -2,7 +2,7 @@ import { render, waitFor, screen, RenderResult } from '@testing-library/react';
import DocumentUploadConfirmStage from './DocumentUploadConfirmStage';
import { formatNhsNumber } from '../../../../helpers/utils/formatNhsNumber';
import { getFormattedDate } from '../../../../helpers/utils/formatDate';
-import { buildPatientDetails } from '../../../../helpers/test/testBuilders';
+import { buildDocumentConfig, buildPatientDetails } from '../../../../helpers/test/testBuilders';
import usePatient from '../../../../helpers/hooks/usePatient';
import {
DOCUMENT_UPLOAD_STATE,
@@ -14,9 +14,16 @@ import userEvent from '@testing-library/user-event';
import { routeChildren, routes } from '../../../../types/generic/routes';
import { getFormattedPatientFullName } from '../../../../helpers/utils/formatPatientFullName';
import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType';
+import { getJourney } from '../../../../helpers/utils/urlManipulations';
-const mockedUseNavigate = vi.fn();
vi.mock('../../../../helpers/hooks/usePatient');
+vi.mock('../../../../helpers/utils/urlManipulations', async () => {
+ const actual = await vi.importActual('../../../../helpers/utils/urlManipulations');
+ return {
+ ...actual,
+ getJourney: vi.fn(),
+ };
+});
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
@@ -25,6 +32,29 @@ vi.mock('react-router-dom', async () => {
};
});
+const mockedUseNavigate = vi.fn();
+
+vi.mock('../documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview', () => ({
+ default: ({
+ documents,
+ stitchedBlobLoaded,
+ }: {
+ documents: UploadDocument[];
+ stitchedBlobLoaded?: (loaded: boolean) => void;
+ }): React.JSX.Element => {
+ // Simulate the PDF stitching completion
+ if (stitchedBlobLoaded) {
+ setTimeout(() => stitchedBlobLoaded(true), 0);
+ }
+ return (
+
+ Lloyd George Preview for documents
+ {documents.length}
+
+ );
+ },
+}));
+
const patientDetails = buildPatientDetails();
URL.createObjectURL = vi.fn();
@@ -34,6 +64,9 @@ let history = createMemoryHistory({
initialIndex: 0,
});
+let docConfig = buildDocumentConfig();
+const mockConfirmFiles = vi.fn();
+
describe('DocumentUploadCompleteStage', () => {
beforeEach(() => {
vi.mocked(usePatient).mockReturnValue(patientDetails);
@@ -53,16 +86,13 @@ describe('DocumentUploadCompleteStage', () => {
});
});
- it('should navigate to next screen when confirm button is clicked', async () => {
+ it('should call confirmFiles when confirm button is clicked', async () => {
renderApp(history, 1);
userEvent.click(await screen.findByTestId('confirm-button'));
await waitFor(() => {
- expect(mockedUseNavigate).toHaveBeenCalledWith({
- pathname: routeChildren.DOCUMENT_UPLOAD_UPLOADING,
- search: '',
- });
+ expect(mockConfirmFiles).toHaveBeenCalled();
});
});
@@ -75,6 +105,70 @@ describe('DocumentUploadCompleteStage', () => {
});
});
+ it.each([
+ { fileCount: 3, expectedPreviewCount: 3, stitched: true },
+ { fileCount: 1, expectedPreviewCount: 1, stitched: false },
+ ])(
+ 'should render correct number files in the preview %s',
+ async ({ fileCount, expectedPreviewCount, stitched }) => {
+ docConfig = buildDocumentConfig({
+ snomedCode: DOCUMENT_TYPE.EHR,
+ stitched,
+ });
+
+ renderApp(history, fileCount);
+
+ await waitFor(async () => {
+ expect(screen.getByTestId('lloyd-george-preview-count').textContent).toBe(
+ `${expectedPreviewCount}`,
+ );
+ });
+ },
+ );
+
+ it.each([
+ {
+ stitched: false,
+ multifile: true,
+ journey: '',
+ expectedText: `Each file will be uploaded as a separate ${docConfig.displayName} for this patient.`,
+ },
+ {
+ stitched: false,
+ multifile: false,
+ journey: '',
+ expectedText: `This file will be uploaded as a new ${docConfig.displayName} for this patient.`,
+ },
+ {
+ stitched: true,
+ multifile: true,
+ journey: 'update',
+ expectedText: `Files will be added to the existing ${docConfig.displayName} to create a single PDF document.`,
+ },
+ {
+ stitched: true,
+ multifile: true,
+ journey: '',
+ expectedText: `Files will be combined into a single PDF document to create a ${docConfig.displayName} record for this patient.`,
+ },
+ ])(
+ 'renders correct text for file result: %s',
+ async ({ stitched, multifile, journey, expectedText }) => {
+ docConfig = buildDocumentConfig({
+ multifileUpload: multifile,
+ multifileReview: multifile,
+ stitched,
+ });
+ vi.mocked(getJourney).mockReturnValueOnce(journey as any);
+
+ renderApp(history, 1);
+
+ await waitFor(async () => {
+ expect(screen.getByText(expectedText)).toBeInTheDocument();
+ });
+ },
+ );
+
describe('Navigation', () => {
it('should navigate to previous screen when go back is clicked', async () => {
renderApp(history, 1);
@@ -93,7 +187,7 @@ describe('DocumentUploadCompleteStage', () => {
await waitFor(() => {
expect(mockedUseNavigate).toHaveBeenCalledWith({
- pathname: routes.DOCUMENT_UPLOAD,
+ pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_FILES,
search: '',
});
});
@@ -123,6 +217,7 @@ describe('DocumentUploadCompleteStage', () => {
describe('Update Journey', () => {
beforeEach(() => {
+ vi.mocked(getJourney).mockReturnValue('update');
delete (globalThis as any).location;
globalThis.location = { search: '?journey=update' } as any;
@@ -138,7 +233,7 @@ describe('DocumentUploadCompleteStage', () => {
await waitFor(async () => {
expect(
screen.getByText(
- 'Files will be added to the existing Lloyd George record to create a single PDF document.',
+ `Files will be added to the existing ${docConfig.displayName} to create a single PDF document.`,
),
).toBeInTheDocument();
});
@@ -151,20 +246,7 @@ describe('DocumentUploadCompleteStage', () => {
await waitFor(() => {
expect(mockedUseNavigate).toHaveBeenCalledWith({
- pathname: routes.DOCUMENT_UPLOAD,
- search: 'journey=update',
- });
- });
- });
-
- it('should navigate with journey param when confirm button is clicked', async () => {
- renderApp(history, 1);
-
- userEvent.click(await screen.findByTestId('confirm-button'));
-
- await waitFor(() => {
- expect(mockedUseNavigate).toHaveBeenCalledWith({
- pathname: routeChildren.DOCUMENT_UPLOAD_UPLOADING,
+ pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_FILES,
search: 'journey=update',
});
});
@@ -199,14 +281,18 @@ describe('DocumentUploadCompleteStage', () => {
attempts: 0,
id: `${i}`,
docType: DOCUMENT_TYPE.LLOYD_GEORGE,
- file: new File(['file'], `file ${i}.pdf`),
+ file: new File(['file'], `file ${i}.pdf`, { type: 'application/pdf' }),
state: DOCUMENT_UPLOAD_STATE.SELECTED,
});
}
return render(
-
+
,
);
};
diff --git a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx
index 5e0ce2090..849e0141f 100644
--- a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx
+++ b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx
@@ -2,25 +2,43 @@ import { Button, Table } from 'nhsuk-react-components';
import useTitle from '../../../../helpers/hooks/useTitle';
import { UploadDocument } from '../../../../types/pages/UploadDocumentsPage/types';
import BackButton from '../../../generic/backButton/BackButton';
-import { routeChildren, routes } from '../../../../types/generic/routes';
+import { routeChildren } from '../../../../types/generic/routes';
import { useState } from 'react';
import Pagination from '../../../generic/pagination/Pagination';
import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/PatientSummary';
import { getJourney, useEnhancedNavigate } from '../../../../helpers/utils/urlManipulations';
-import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType';
+import { DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType';
+import DocumentUploadLloydGeorgePreview from '../documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview';
+import SpinnerButton from '../../../generic/spinnerButton/SpinnerButton';
type Props = {
documents: UploadDocument[];
+ documentConfig: DOCUMENT_TYPE_CONFIG;
+ confirmFiles: () => void;
};
-const DocumentUploadConfirmStage = ({ documents }: Props): React.JSX.Element => {
+const DocumentUploadConfirmStage = ({
+ documents,
+ documentConfig,
+ confirmFiles,
+}: Props): React.JSX.Element => {
const [currentPage, setCurrentPage] = useState
(0);
const navigate = useEnhancedNavigate();
const pageSize = 10;
const journey = getJourney();
+ const [stitchedBlobLoaded, setStitchedBlobLoaded] = useState(false);
+ const [currentPreviewDocument, setCurrentPreviewDocument] = useState<
+ UploadDocument | undefined
+ >(
+ documents.length === 1
+ ? documents.find((doc) => doc.file.type === 'application/pdf')
+ : undefined,
+ );
+ const [processingFiles, setProcessingFiles] = useState(false);
+
+ const multifile = documentConfig.multifileReview || documentConfig.multifileUpload;
- const pageTitle = 'Check your files before uploading';
- useTitle({ pageTitle });
+ useTitle({ pageTitle: documentConfig.content.confirmFilesTitle as string });
const currentItems = (): UploadDocument[] => {
const skipCount = currentPage * pageSize;
@@ -31,13 +49,50 @@ const DocumentUploadConfirmStage = ({ documents }: Props): React.JSX.Element =>
return Math.ceil(documents.length / pageSize);
};
+ const getDocumentsForPreview = (): UploadDocument[] => {
+ const docs = [];
+
+ if (documentConfig.stitched) {
+ docs.push(...documents);
+ } else if (currentPreviewDocument) {
+ docs.push(currentPreviewDocument);
+ }
+
+ return docs.sort((a, b) => a.position! - b.position!);
+ };
+
+ const getFileActionParagraph = (): string => {
+ if (documentConfig.stitched) {
+ if (journey === 'update') {
+ return `Files will be added to the existing ${documentConfig.displayName} to create a single PDF document.`;
+ }
+
+ return `Files will be combined into a single PDF document to create a ${documentConfig.displayName} record for this patient.`;
+ }
+
+ return multifile
+ ? `Each file will be uploaded as a separate ${documentConfig.displayName} for this patient.`
+ : `This file will be uploaded as a new ${documentConfig.displayName} for this patient.`;
+ };
+
+ const confirmClicked = (): void => {
+ if (documentConfig.multifileZipped) {
+ setProcessingFiles(true);
+ }
+ confirmFiles();
+ };
+
return (
-
{pageTitle}
+
{documentConfig.content.confirmFilesTitle}
-
Make sure that all files uploaded are for this patient only:
+
+ {multifile
+ ? 'Make sure that all files uploaded are for this patient only:'
+ : 'Make sure that the uploaded file is for this patient only:'}
+
@@ -45,34 +100,37 @@ const DocumentUploadConfirmStage = ({ documents }: Props): React.JSX.Element =>
-
- {journey === 'update'
- ? 'Files will be added to the existing Lloyd George record to create a single PDF document.'
- : 'Files will be combined into a single PDF document to create a Lloyd George record for this patient.'}
-
+
{getFileActionParagraph()}
-
Files to be uploaded
+
File{multifile ? 's' : ''} to be uploaded
Filename
-
- Position
-
-
-
-
+ {documentConfig.stitched && (
+
+ Position
+
+ )}
+ {multifile && !documentConfig.stitched && Preview}
+ {multifile && (
+
+
+
+ )}
@@ -85,13 +143,28 @@ const DocumentUploadConfirmStage = ({ documents }: Props): React.JSX.Element =>
{document.file.name}
-
-
- {document.docType === DOCUMENT_TYPE.LLOYD_GEORGE
- ? document.position
- : 'N/A'}
-
-
+ {documentConfig.stitched && (
+ {document.position}
+ )}
+ {!documentConfig.stitched && documents.length > 1 && (
+
+ {document.file.type === 'application/pdf' ? (
+
+ ) : (
+ '-'
+ )}
+
+ )}
);
@@ -105,12 +178,39 @@ const DocumentUploadConfirmStage = ({ documents }: Props): React.JSX.Element =>
setCurrentPage={setCurrentPage}
/>
-
+ {(documentConfig.stitched || currentPreviewDocument) && (
+
+ {
+ setStitchedBlobLoaded(loaded);
+ }}
+ documentConfig={documentConfig}
+ />
+
+ )}
+
+ {(!documentConfig.stitched || stitchedBlobLoaded) && !processingFiles && (
+
+ )}
+ {processingFiles && (
+
+ )}
+ {documentConfig.stitched && !stitchedBlobLoaded && (
+
+ )}
);
};
diff --git a/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.test.tsx b/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.test.tsx
new file mode 100644
index 000000000..8b86cfa46
--- /dev/null
+++ b/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.test.tsx
@@ -0,0 +1,255 @@
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { BrowserRouter } from 'react-router-dom';
+import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
+import DocumentUploadIndex from './DocumentUploadIndex';
+import usePatient from '../../../../helpers/hooks/usePatient';
+import useBaseAPIUrl from '../../../../helpers/hooks/useBaseAPIUrl';
+import useBaseAPIHeaders from '../../../../helpers/hooks/useBaseAPIHeaders';
+import getDocument from '../../../../helpers/requests/getDocument';
+import getDocumentSearchResults from '../../../../helpers/requests/getDocumentSearchResults';
+import { isMock } from '../../../../helpers/utils/isLocal';
+import axios, { AxiosError } from 'axios';
+import { routeChildren, routes } from '../../../../types/generic/routes';
+import userEvent from '@testing-library/user-event';
+import { DOCUMENT_TYPE_CONFIG, getConfigForDocType } from '../../../../helpers/utils/documentType';
+
+vi.mock('../../../../styles/right-chevron-circle.svg', () => ({
+ ReactComponent: () => 'svg',
+}));
+vi.mock('../../../../helpers/hooks/usePatient');
+vi.mock('../../../../helpers/hooks/useBaseAPIUrl');
+vi.mock('../../../../helpers/hooks/useBaseAPIHeaders');
+vi.mock('../../../../helpers/requests/getDocument');
+vi.mock('../../../../helpers/requests/getDocumentSearchResults');
+vi.mock('../../../../helpers/utils/isLocal');
+vi.mock('axios');
+vi.mock('../../../../helpers/utils/documentType', async () => {
+ const actual = await vi.importActual('../../../../helpers/utils/documentType');
+ return {
+ ...actual,
+ getConfigForDocType: vi.fn(),
+ };
+});
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+vi.mock('../../../generic/patientSummary/PatientSummary', () => ({
+ default: () => Patient Summary Component
,
+}));
+
+const mockSetDocumentType = vi.fn();
+const mockSetJourney = vi.fn();
+const mockUpdateExistingDocuments = vi.fn();
+const mockNavigate = vi.fn();
+
+const defaultProps = {
+ setDocumentType: mockSetDocumentType,
+ setJourney: mockSetJourney,
+ updateExistingDocuments: mockUpdateExistingDocuments,
+};
+
+const mockPatient = {
+ nhsNumber: '1234567890',
+ givenName: ['John'],
+ familyName: 'Doe',
+ birthDate: '1990-01-01',
+ postalCode: 'AB1 2CD',
+};
+
+const renderComponent = (props = defaultProps) => {
+ return render(
+
+
+ ,
+ );
+};
+
+describe('DocumentUploadIndex', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ (usePatient as Mock).mockReturnValue(mockPatient);
+ (useBaseAPIUrl as Mock).mockReturnValue('http://localhost:3000');
+ (useBaseAPIHeaders as Mock).mockReturnValue({});
+ (getConfigForDocType as Mock).mockReturnValue({
+ singleDocumentOnly: true,
+ } as DOCUMENT_TYPE_CONFIG);
+ });
+
+ it('renders the page title correctly', () => {
+ renderComponent();
+ expect(screen.getByTestId('page-title')).toBeInTheDocument();
+ expect(screen.getByText('Choose a document type to upload')).toBeInTheDocument();
+ });
+
+ it('renders document type cards for each configured document type', () => {
+ renderComponent();
+ const uploadLinks = screen.getAllByTestId(/upload-\d+-link/);
+ expect(uploadLinks.length).toBeGreaterThan(0);
+ });
+
+ it('handles document type selection for non-single document types', async () => {
+ (getConfigForDocType as Mock).mockReturnValueOnce({
+ singleDocumentOnly: false,
+ } as DOCUMENT_TYPE_CONFIG);
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(mockSetDocumentType).toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(routeChildren.DOCUMENT_UPLOAD_SELECT_FILES);
+ });
+ });
+
+ it('shows spinner when loading next document', async () => {
+ (getDocumentSearchResults as Mock).mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve([]), 1000)),
+ );
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Loading existing document...')).toBeInTheDocument();
+ });
+ });
+
+ it('navigates to server error when patient details are missing', async () => {
+ (usePatient as Mock).mockReturnValue(null);
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR);
+ });
+ });
+
+ it('handles single document type with no existing documents', async () => {
+ (getDocumentSearchResults as Mock).mockResolvedValue([]);
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(routeChildren.DOCUMENT_UPLOAD_SELECT_FILES);
+ });
+ });
+
+ it('handles single document type with existing document', async () => {
+ const mockSearchResult = {
+ id: 'doc-123',
+ fileName: 'test.pdf',
+ version: '1',
+ };
+
+ (getDocumentSearchResults as Mock).mockResolvedValue([mockSearchResult]);
+ (getDocument as Mock).mockResolvedValue({ url: 'http://example.com/doc.pdf' });
+ global.fetch = vi.fn().mockResolvedValue({
+ blob: () => Promise.resolve(new Blob(['test'], { type: 'application/pdf' })),
+ });
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(mockSetJourney).toHaveBeenCalledWith('update');
+ expect(mockUpdateExistingDocuments).toHaveBeenCalledWith([
+ expect.objectContaining({
+ fileName: 'test.pdf',
+ documentId: 'doc-123',
+ versionId: '1',
+ }),
+ ]);
+ });
+ });
+
+ it('handles search error', async () => {
+ const mockError = new AxiosError('Network Error');
+ (getDocumentSearchResults as Mock).mockRejectedValue(mockError);
+ (isMock as Mock).mockReturnValue(true);
+ (axios.get as Mock).mockResolvedValue({
+ data: new Blob(['mock data'], { type: 'application/pdf' }),
+ });
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(mockUpdateExistingDocuments).toHaveBeenCalledWith([
+ expect.objectContaining({
+ fileName: 'testFile.pdf',
+ documentId: 'mock-document-id',
+ versionId: '1',
+ }),
+ ]);
+ });
+ });
+
+ it('handles 403 error and redirects to session expired', async () => {
+ const mockError = new AxiosError('Forbidden');
+ mockError.response = { status: 403 } as any;
+ (getDocumentSearchResults as Mock).mockRejectedValue(mockError);
+ (isMock as Mock).mockReturnValue(false);
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED);
+ });
+ });
+
+ it('handles other errors and redirects to server error with message', async () => {
+ const mockError = new AxiosError();
+ mockError.message = 'Server Error';
+ mockError.response = { status: 500 } as any;
+ (getDocumentSearchResults as Mock).mockRejectedValue(mockError);
+ (isMock as Mock).mockReturnValue(false);
+
+ renderComponent();
+ const firstUploadLink = screen.getAllByTestId(/upload-\d+-link/)[0];
+
+ await act(async () => {
+ userEvent.click(firstUploadLink);
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ `${routes.SERVER_ERROR}?message=Server%20Error`,
+ );
+ });
+ });
+});
diff --git a/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.tsx b/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.tsx
new file mode 100644
index 000000000..12012ed67
--- /dev/null
+++ b/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.tsx
@@ -0,0 +1,176 @@
+import documentTypesConfig from '../../../../config/documentTypesConfig.json';
+import { Card } from 'nhsuk-react-components';
+import { ReactComponent as RightCircleIcon } from '../../../../styles/right-chevron-circle.svg';
+import getDocument from '../../../../helpers/requests/getDocument';
+import getDocumentSearchResults from '../../../../helpers/requests/getDocumentSearchResults';
+import { Dispatch, SetStateAction, useState } from 'react';
+import { DOCUMENT_TYPE, getConfigForDocType } from '../../../../helpers/utils/documentType';
+import { JourneyType } from '../../../../helpers/utils/urlManipulations';
+import { ExistingDocument } from '../../../../types/pages/UploadDocumentsPage/types';
+import { createSearchParams, NavigateOptions, To, useNavigate } from 'react-router-dom';
+import usePatient from '../../../../helpers/hooks/usePatient';
+import useBaseAPIUrl from '../../../../helpers/hooks/useBaseAPIUrl';
+import useBaseAPIHeaders from '../../../../helpers/hooks/useBaseAPIHeaders';
+import { routeChildren, routes } from '../../../../types/generic/routes';
+import axios, { AxiosError } from 'axios';
+import { isMock } from '../../../../helpers/utils/isLocal';
+import PatientSummary from '../../../generic/patientSummary/PatientSummary';
+import Spinner from '../../../generic/spinner/Spinner';
+
+type DocumentUploadIndexProps = {
+ setDocumentType: Dispatch>;
+ setJourney: Dispatch>;
+ updateExistingDocuments: (existingDocuments: ExistingDocument[]) => void;
+};
+
+const DocumentUploadIndex = ({
+ setDocumentType,
+ setJourney,
+ updateExistingDocuments,
+}: DocumentUploadIndexProps): React.JSX.Element => {
+ const navigate = useNavigate();
+ const patientDetails = usePatient();
+ const baseUrl = useBaseAPIUrl();
+ const baseHeaders = useBaseAPIHeaders();
+ const [loadingNext, setLoadingNext] = useState(false);
+
+ const documentTypeSelected = async (documentType: DOCUMENT_TYPE): Promise => {
+ const config = getConfigForDocType(documentType);
+
+ if (config.singleDocumentOnly) {
+ await handleSingleDocumentOnlyTypeSelected(documentType);
+ setDocumentType(documentType);
+ return;
+ }
+
+ setDocumentType(documentType);
+ navigate(routeChildren.DOCUMENT_UPLOAD_SELECT_FILES);
+ };
+
+ const loadDocument = async (documentId: string) => {
+ const documentResponse = await getDocument({
+ nhsNumber: patientDetails!.nhsNumber,
+ baseUrl,
+ baseHeaders,
+ documentId,
+ });
+
+ return documentResponse;
+ };
+
+ const handleSingleDocumentOnlyTypeSelected = async (docType: DOCUMENT_TYPE): Promise => {
+ if (!patientDetails?.nhsNumber) {
+ navigate(routes.SERVER_ERROR);
+ return;
+ }
+
+ const handleSuccess = (existingDocument: ExistingDocument): void => {
+ const to: To = {
+ pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_FILES,
+ search: createSearchParams({ journey: 'update' }).toString(),
+ };
+ const options: NavigateOptions = {
+ state: {
+ journey: 'update',
+ existingDocuments: [existingDocument],
+ },
+ };
+
+ setLoadingNext(false);
+ setJourney('update');
+ updateExistingDocuments([existingDocument]);
+ navigate(to, options);
+ };
+
+ try {
+ setLoadingNext(true);
+
+ const searchResults = await getDocumentSearchResults({
+ nhsNumber: patientDetails.nhsNumber,
+ baseUrl: baseUrl,
+ baseHeaders: baseHeaders,
+ docType,
+ });
+
+ if (searchResults.length === 0) {
+ navigate(routeChildren.DOCUMENT_UPLOAD_SELECT_FILES);
+ return;
+ }
+
+ const getDocumentResponse = await loadDocument(searchResults[0].id);
+
+ const existingDoc: ExistingDocument = {
+ fileName: searchResults[0].fileName,
+ documentId: searchResults[0].id,
+ versionId: searchResults[0].version,
+ docType,
+ blob: null,
+ };
+
+ const response = await fetch(getDocumentResponse.url);
+ existingDoc.blob = await response.blob();
+
+ handleSuccess(existingDoc);
+ } catch (e) {
+ const error = e as AxiosError;
+
+ if (isMock(error)) {
+ const { data } = await axios.get('/dev/testFile.pdf', {
+ responseType: 'blob',
+ });
+ handleSuccess({
+ fileName: 'testFile.pdf',
+ documentId: 'mock-document-id',
+ versionId: '1',
+ docType,
+ blob: data,
+ });
+ } else if (error.response?.status === 403) {
+ navigate(routes.SESSION_EXPIRED);
+ } else {
+ navigate(routes.SERVER_ERROR + `?message=${encodeURIComponent(error.message)}`);
+ }
+ }
+ };
+
+ return (
+ <>
+ Choose a document type to upload
+
+
+
+ {loadingNext ? (
+
+ ) : (
+
+ {documentTypesConfig.map((documentConfig) => (
+
+
+
+
+ =>
+ documentTypeSelected(
+ documentConfig.snomed_code as DOCUMENT_TYPE,
+ )
+ }
+ >
+ {documentConfig.content.upload_title}
+
+
+
+ {documentConfig.content.upload_description}
+
+
+
+
+
+ ))}
+
+ )}
+ >
+ );
+};
+
+export default DocumentUploadIndex;
diff --git a/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.test.tsx b/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.test.tsx
index 5fc8fccfb..e42913045 100644
--- a/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.test.tsx
+++ b/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.test.tsx
@@ -6,6 +6,7 @@ import {
import DocumentUploadLloydGeorgePreview from './DocumentUploadLloydGeorgePreview';
import getMergedPdfBlob from '../../../../helpers/utils/pdfMerger';
import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType';
+import { buildDocumentConfig } from '../../../../helpers/test/testBuilders';
const mockNavigate = vi.fn();
@@ -25,6 +26,8 @@ const createMockDocument = (id: string): UploadDocument => ({
attempts: 0,
});
+const docConfig = buildDocumentConfig();
+
describe('DocumentUploadCompleteStage', () => {
let documents: UploadDocument[];
const mockSetMergedPdfBlob = vi.fn();
@@ -48,6 +51,7 @@ describe('DocumentUploadCompleteStage', () => {
,
);
});
@@ -68,6 +72,7 @@ describe('DocumentUploadCompleteStage', () => {
documents={testDocuments}
setMergedPdfBlob={mockSetMergedPdfBlob}
stitchedBlobLoaded={stitchedBlobLoaded}
+ documentConfig={docConfig}
/>,
);
});
diff --git a/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx b/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx
index e3be58cbe..35cc1ff17 100644
--- a/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx
+++ b/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx
@@ -2,19 +2,24 @@ import { UploadDocument } from '../../../../types/pages/UploadDocumentsPage/type
import { JSX, useEffect, useRef, useState } from 'react';
import PdfViewer from '../../../generic/pdfViewer/PdfViewer';
import getMergedPdfBlob from '../../../../helpers/utils/pdfMerger';
+import { DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType';
+import { getJourney } from '../../../../helpers/utils/urlManipulations';
type Props = {
documents: UploadDocument[];
- setMergedPdfBlob: (blob: Blob) => void;
+ setMergedPdfBlob?: (blob: Blob) => void;
stitchedBlobLoaded?: (value: boolean) => void;
+ documentConfig: DOCUMENT_TYPE_CONFIG;
};
const DocumentUploadLloydGeorgePreview = ({
documents,
setMergedPdfBlob,
stitchedBlobLoaded,
+ documentConfig,
}: Props): JSX.Element => {
const [mergedPdfUrl, setMergedPdfUrl] = useState('');
+ const journey = getJourney();
const runningRef = useRef(false);
useEffect(() => {
@@ -36,7 +41,9 @@ const DocumentUploadLloydGeorgePreview = ({
}
const blob = await getMergedPdfBlob(documents.map((doc) => doc.file));
- setMergedPdfBlob(blob);
+ if (setMergedPdfBlob) {
+ setMergedPdfBlob(blob);
+ }
const url = URL.createObjectURL(blob);
runningRef.current = false;
@@ -55,6 +62,23 @@ const DocumentUploadLloydGeorgePreview = ({
return (
<>
+ {documentConfig.content.previewUploadTitle}
+ {documentConfig.stitched ? (
+ <>
+
+ This shows how the final record will look when combined into a single
+ document.{' '}
+ {journey === 'update' &&
+ `Any files added will appear after the existing ${documentConfig.displayName}.`}
+
+
+ Preview may take longer to load if there are many files or if individual
+ files are large.
+
+ >
+ ) : (
+ The preview is currently displaying the file: {documents[0]?.file.name}
+ )}
{documents && mergedPdfUrl && (
)}
diff --git a/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.test.tsx b/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.test.tsx
index 2c957ff46..0bae9bda0 100644
--- a/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.test.tsx
+++ b/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.test.tsx
@@ -4,7 +4,11 @@ import {
UploadDocument,
} from '../../../../types/pages/UploadDocumentsPage/types';
import DocumentUploadingStage from './DocumentUploadingStage';
-import { buildLgFile } from '../../../../helpers/test/testBuilders';
+import {
+ buildDocument,
+ buildDocumentConfig,
+ buildLgFile,
+} from '../../../../helpers/test/testBuilders';
import { MemoryRouter } from 'react-router-dom';
import { routes } from '../../../../types/generic/routes';
import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType';
@@ -22,6 +26,8 @@ vi.mock('react-router-dom', async () => {
URL.createObjectURL = vi.fn();
+const docConfig = buildDocumentConfig();
+
describe('DocumentUploadCompleteStage', () => {
let documents: UploadDocument[];
@@ -75,7 +81,11 @@ describe('DocumentUploadCompleteStage', () => {
const renderApp = (documents: UploadDocument[]) => {
render(
-
+
,
);
};
diff --git a/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.tsx b/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.tsx
index 993a098b6..55331f8d8 100644
--- a/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.tsx
+++ b/app/src/components/blocks/_documentUpload/documentUploadingStage/DocumentUploadingStage.tsx
@@ -9,14 +9,19 @@ import { useNavigate } from 'react-router-dom';
import { routes } from '../../../../types/generic/routes';
import { allDocsHaveState } from '../../../../helpers/utils/uploadDocumentHelpers';
import { getJourney } from '../../../../helpers/utils/urlManipulations';
-import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType';
+import { DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType';
type Props = {
documents: UploadDocument[];
startUpload: () => Promise;
+ documentConfig: DOCUMENT_TYPE_CONFIG;
};
-const DocumentUploadingStage = ({ documents, startUpload }: Props): React.JSX.Element => {
+const DocumentUploadingStage = ({
+ documents,
+ startUpload,
+ documentConfig,
+}: Props): React.JSX.Element => {
const journey = getJourney();
const pageHeader =
journey === 'update' ? 'Uploading additional files' : 'Your documents are uploading';
@@ -27,7 +32,7 @@ const DocumentUploadingStage = ({ documents, startUpload }: Props): React.JSX.El
useEffect(() => {
if (!uploadStartedRef.current) {
- if (!allDocsHaveState(documents, DOCUMENT_UPLOAD_STATE.SELECTED)) {
+ if (!allDocsHaveState(documents, [DOCUMENT_UPLOAD_STATE.SELECTED])) {
navigate(routes.HOME);
return;
}
@@ -37,7 +42,10 @@ const DocumentUploadingStage = ({ documents, startUpload }: Props): React.JSX.El
}
}, []);
- if (!uploadStartedRef.current && !allDocsHaveState(documents, DOCUMENT_UPLOAD_STATE.SELECTED)) {
+ if (
+ !uploadStartedRef.current &&
+ !allDocsHaveState(documents, [DOCUMENT_UPLOAD_STATE.SELECTED])
+ ) {
return <>>;
}
@@ -47,13 +55,12 @@ const DocumentUploadingStage = ({ documents, startUpload }: Props): React.JSX.El
Stay on this page
- Do not close or navigate away from this page until upload is complete.{' '}
- {documents.some((doc) => doc.docType === DOCUMENT_TYPE.LLOYD_GEORGE) && (
+ Do not close or navigate away from this page until the upload is complete.{' '}
+ {documentConfig.stitched && (
- Your Lloyd George files will be combined into one document when the
- upload is complete.{' '}
- {journey === 'update' &&
- 'your files will be added to the existing Lloyd George record when upload is complete.'}
+ {journey === 'update'
+ ? 'Your files will be added to the existing record when the upload is complete.'
+ : 'Your files will be combined into one document when the upload is complete.'}
)}
diff --git a/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx b/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx
index 685375962..8a76bfe6f 100644
--- a/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx
+++ b/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx
@@ -5,7 +5,7 @@ import useConfig from '../../../../helpers/hooks/useConfig';
import usePatient from '../../../../helpers/hooks/usePatient';
import useRole from '../../../../helpers/hooks/useRole';
import useTitle from '../../../../helpers/hooks/useTitle';
-import { generateFileName } from '../../../../helpers/requests/uploadDocuments';
+import { generateStitchedFileName } from '../../../../helpers/requests/uploadDocuments';
import { useSessionContext } from '../../../../providers/sessionProvider/SessionProvider';
import { getUserRecordActionLinks } from '../../../../types/blocks/lloydGeorgeActions';
import { LG_RECORD_STAGE } from '../../../../types/blocks/lloydGeorgeStages';
@@ -26,6 +26,7 @@ import { AxiosError } from 'axios';
import { SearchResult } from '../../../../types/generic/searchResult';
import { isMock } from '../../../../helpers/utils/isLocal';
import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType';
+import lloydGeorgeConfig from '../../../../config/lloydGeorgeConfig.json';
export type Props = {
downloadStage: DOWNLOAD_STAGE;
@@ -163,7 +164,7 @@ const LloydGeorgeViewRecordStage = ({
handleSuccess([
{
id: 'mock-document-id',
- fileName: generateFileName(patientDetails),
+ fileName: generateStitchedFileName(patientDetails, lloydGeorgeConfig),
version: 'mock-version-id',
created: new Date().toISOString(),
fileSize: 12345,
diff --git a/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx b/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx
index 476bd6a99..51a08db20 100644
--- a/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx
+++ b/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx
@@ -24,6 +24,7 @@ import { usePatientAccessAuditContext } from '../../../../providers/patientAcces
import SpinnerButton from '../../../generic/spinnerButton/SpinnerButton';
import postPatientAccessAudit from '../../../../helpers/requests/postPatientAccessAudit';
import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/PatientSummary';
+import useConfig from '../../../../helpers/hooks/useConfig';
enum FORM_FIELDS {
Reasons = 'reasons',
@@ -77,6 +78,7 @@ const DeceasedPatientAccessAudit = (): React.JSX.Element => {
);
const [submitting, setSubmitting] = useState(false);
const scrollToErrorRef = useRef(null);
+ const config = useConfig();
/* HOOKS */
if (!patientDetails) {
@@ -188,7 +190,11 @@ const DeceasedPatientAccessAudit = (): React.JSX.Element => {
];
setPatientAccessAudit(newPatientAccessAudit);
- navigate(routes.LLOYD_GEORGE);
+ navigate(
+ config.featureFlags.uploadDocumentIteration3Enabled
+ ? routes.PATIENT_DOCUMENTS
+ : routes.LLOYD_GEORGE,
+ );
};
const handleError = (fields: FieldValues): void => {
diff --git a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.scss b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.scss
index 0ffc1a623..2a1a62917 100644
--- a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.scss
+++ b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.scss
@@ -1,6 +1,14 @@
.document-search-results {
- #available-files-table-title {
- .table-column-header, .nhsuk-table-responsive__heading {
+ .subtitle {
+ margin-bottom: 1rem;
+ }
+
+ #table-panel {
+ padding-top: 1rem !important;
+ margin-top: 0;
+
+ .table-column-header,
+ .nhsuk-table-responsive__heading {
word-break: keep-all;
}
@@ -8,4 +16,4 @@
word-break: break-word;
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx
index 6e1bd4a5f..211ba9778 100644
--- a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx
+++ b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx
@@ -3,15 +3,16 @@ import { SearchResult } from '../../../../types/generic/searchResult';
import { useSessionContext } from '../../../../providers/sessionProvider/SessionProvider';
import { REPOSITORY_ROLE } from '../../../../types/generic/authRole';
import { getFormattedDate } from '../../../../helpers/utils/formatDate';
-import { getDocumentTypeLabel } from '../../../../helpers/utils/documentType';
+import { DOCUMENT_TYPE_CONFIG, getDocumentTypeLabel } from '../../../../helpers/utils/documentType';
import LinkButton from '../../../generic/linkButton/LinkButton';
type Props = {
searchResults: Array;
onViewDocument?: (document: SearchResult) => void;
+ documentConfig?: DOCUMENT_TYPE_CONFIG;
};
-const DocumentSearchResults = (props: Props) => {
+const DocumentSearchResults = ({ searchResults, onViewDocument, documentConfig }: Props) => {
const [session] = useSessionContext();
const canViewFiles =
@@ -19,9 +20,11 @@ const DocumentSearchResults = (props: Props) => {
session.auth!.role === REPOSITORY_ROLE.GP_CLINICAL;
return (
-
-
Records and documents stored for this patient
-
+
+
+ Records and documents stored for this patient
+
+
@@ -32,7 +35,7 @@ const DocumentSearchResults = (props: Props) => {
- {props.searchResults?.map((result, index) => (
+ {searchResults?.map((result, index) => (
{
- {result.fileName}
+ {documentConfig?.filenameOverride
+ ? documentConfig.filenameOverride
+ : result.fileName}
{
id={`available-files-row-${index}-actions`}
data-testid="actions"
>
- {canViewFiles && props.onViewDocument && (
+ {canViewFiles && onViewDocument && (
props.onViewDocument!(result)}
+ onClick={() => onViewDocument(result)}
id={`available-files-row-${index}-view-link`}
data-testid={`view-${index}-link`}
href="#"
+ className="px-1 py-1"
>
View
diff --git a/app/src/components/blocks/_patientDocuments/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx b/app/src/components/blocks/_patientDocuments/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx
index b76cb01a1..9e92f060f 100644
--- a/app/src/components/blocks/_patientDocuments/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx
+++ b/app/src/components/blocks/_patientDocuments/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx
@@ -86,7 +86,12 @@ const DocumentSearchResultsOptions = (props: Props): React.JSX.Element => {
disabled={true}
/>
) : (
-