diff --git a/.github/workflows/base-deploy-ui.yml b/.github/workflows/base-deploy-ui.yml index 61e1d4d35..fecd8dc01 100644 --- a/.github/workflows/base-deploy-ui.yml +++ b/.github/workflows/base-deploy-ui.yml @@ -108,10 +108,12 @@ jobs: IMAGE_TAG: latest IMAGE_TAG_SHA: ${{ github.sha }} CONTAINER_PORT: ${{ env.CONTAINER_PORT }} + BUILD_ENV: ${{ inputs.environment }} run: | docker build \ --build-arg="CONTAINER_PORT=$CONTAINER_PORT" \ --build-arg="CLOUDFRONT_DOMAIN_NAME=$CLOUDFRONT_DOMAIN_NAME" \ + --build-arg="BUILD_ENV=$BUILD_ENV" \ -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \ -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG_SHA . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG diff --git a/app/Dockerfile b/app/Dockerfile index 8c6806aa8..8ce9a521a 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -13,7 +13,10 @@ RUN mkdir -p ./public/pdfjs RUN wget https://github.com/mozilla/pdf.js/releases/download/v5.4.296/pdfjs-5.4.296-dist.zip -O ./public/pdfjs/pdfjs.zip RUN unzip -o -d ./public/pdfjs ./public/pdfjs/pdfjs.zip RUN rm ./public/pdfjs/pdfjs.zip -RUN npm run build + +ARG BUILD_ENV=development +ARG BUILD_ENV=$BUILD_ENV +RUN npm run build -- --mode $BUILD_ENV # Host the App FROM nginx:latest diff --git a/app/cypress/e2e/0-ndr-core-tests/auth_routes/auth_gp_clinical_path_access.cy.js b/app/cypress/e2e/0-ndr-core-tests/auth_routes/auth_gp_clinical_path_access.cy.js index 9135d762a..5d905ce01 100644 --- a/app/cypress/e2e/0-ndr-core-tests/auth_routes/auth_gp_clinical_path_access.cy.js +++ b/app/cypress/e2e/0-ndr-core-tests/auth_routes/auth_gp_clinical_path_access.cy.js @@ -48,4 +48,4 @@ describe('GP Clinical user role has access to the expected GP_CLINICAL workflow cy.url().should('eq', baseUrl + lloydGeorgeViewUrl); }); }); -}); \ No newline at end of file +}); diff --git a/app/package-lock.json b/app/package-lock.json index c848769c0..afce5b0f7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@vitejs/plugin-react-swc": "^4.2.2", + "@zip.js/zip.js": "^2.8.11", "aws-rum-web": "^1.25.0", "axios": "^1.13.2", "dotenv": "^17.2.3", @@ -5299,6 +5300,19 @@ "node": ">= 6" } }, + "node_modules/@cypress/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@cypress/request/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -9886,6 +9900,17 @@ "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", "license": "MIT" }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.11", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.11.tgz", + "integrity": "sha512-0fztsk/0ryJ+2PPr9EyXS5/Co7OK8q3zY/xOoozEWaUsL5x+C0cyZ4YyMuUffOO2Dx/rAdq4JMPqW0VUtm+vzA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -13086,6 +13111,18 @@ "node": ">= 6" } }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -15610,18 +15647,6 @@ "node": ">= 0.6" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", diff --git a/app/package.json b/app/package.json index 9f8792e06..f313a53cc 100644 --- a/app/package.json +++ b/app/package.json @@ -28,6 +28,7 @@ "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@vitejs/plugin-react-swc": "^4.2.2", + "@zip.js/zip.js": "^2.8.11", "aws-rum-web": "^1.25.0", "axios": "^1.13.2", "dotenv": "^17.2.3", @@ -79,8 +80,8 @@ "@types/uuid": "^11.0.0", "@types/validator": "^13.15.10", "@vitest/coverage-v8": "^4.0.13", - "@vitest/ui": "^4.0.13", "@vitest/spy": "^4.0.13", + "@vitest/ui": "^4.0.13", "babel-plugin-named-exports-order": "^0.0.2", "cypress": "^15.7.0", "cypress-real-events": "^1.15.0", diff --git a/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx b/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx index 0239367db..754cdc7a0 100644 --- a/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx +++ b/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx @@ -7,13 +7,14 @@ import { } from '../../../../types/pages/UploadDocumentsPage/types'; import { MemoryRouter } from 'react-router-dom'; import { fileUploadErrorMessages } from '../../../../helpers/utils/fileUploadErrorMessages'; -import { buildLgFile } from '../../../../helpers/test/testBuilders'; +import { buildDocumentConfig, buildLgFile } from '../../../../helpers/test/testBuilders'; import { Mock } from 'vitest'; -import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType'; +import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType'; const mockNavigate = vi.fn(); const mockSetDocuments = vi.fn(); const mockSetMergedPdfBlob = vi.fn(); +const mockConfirmFiles = vi.fn(); vi.mock('../../../../helpers/hooks/usePatient'); vi.mock('../../../../helpers/hooks/useTitle'); @@ -44,17 +45,21 @@ vi.mock('../documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview', } return (
- Lloyd George Preview for {documents.length} documents +

{docConfig.content.previewUploadTitle}

+

Previewing: {documents[0]?.file.name}

); }, })); +let docConfig: DOCUMENT_TYPE_CONFIG; + describe('DocumentSelectOrderStage', () => { let documents: UploadDocument[] = []; beforeEach(() => { import.meta.env.VITE_ENVIRONMENT = 'vitest'; + docConfig = buildDocumentConfig(); documents = [ { docType: DOCUMENT_TYPE.LLOYD_GEORGE, @@ -128,7 +133,9 @@ describe('DocumentSelectOrderStage', () => { renderSut(documents); await waitFor(() => { - expect(screen.getByText('Preview this Lloyd George record')).toBeInTheDocument(); + expect( + screen.getByText(docConfig.content.previewUploadTitle as string), + ).toBeInTheDocument(); expect(screen.getByTestId('lloyd-george-preview')).toBeInTheDocument(); }); }); @@ -476,7 +483,46 @@ describe('DocumentSelectOrderStage', () => { await waitFor(() => { expect( - screen.getByText('Lloyd George Preview for 2 documents'), + screen.getByText(docConfig.content.previewUploadTitle as string), + ).toBeInTheDocument(); + }); + }); + + it('loads currently selected document into PDF viewer', async () => { + docConfig = buildDocumentConfig({ + stitched: false, + snomedCode: DOCUMENT_TYPE.EHR_ATTACHMENTS, + }); + + const multipleDocuments = [ + { + docType: DOCUMENT_TYPE.EHR_ATTACHMENTS, + id: '1', + file: buildLgFile(1), + attempts: 0, + state: DOCUMENT_UPLOAD_STATE.SELECTED, + numPages: 5, + position: 1, + }, + { + docType: DOCUMENT_TYPE.EHR_ATTACHMENTS, + id: '2', + file: buildLgFile(2), + attempts: 0, + state: DOCUMENT_UPLOAD_STATE.SELECTED, + numPages: 3, + position: 2, + }, + ]; + + renderSut(multipleDocuments); + + await waitFor(() => { + expect( + screen.getByText(docConfig.content.previewUploadTitle as string), + ).toBeInTheDocument(); + expect( + screen.getByText(`Previewing: ${multipleDocuments[0].file.name}`), ).toBeInTheDocument(); }); }); @@ -502,10 +548,7 @@ describe('DocumentSelectOrderStage', () => { await user.click(continueButton!); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith({ - pathname: '/patient/document-upload/in-progress', - search: 'journey=update', - }); + expect(mockConfirmFiles).toHaveBeenCalled(); }); }); @@ -544,7 +587,7 @@ describe('DocumentSelectOrderStage', () => { expect( screen.getByText( - "When you upload your files, they will be added to the end of the patient's existing Lloyd George record.", + `When you upload your files, they will be added to the end of the patient's existing ${docConfig.displayName}.`, ), ).toBeInTheDocument(); expect( @@ -562,7 +605,7 @@ describe('DocumentSelectOrderStage', () => { expect( screen.getByText( - "When you upload your files, they will be added to the end of the patient's existing Lloyd George record.", + `When you upload your files, they will be added to the end of the patient's existing ${docConfig.displayName}.`, ), ).toBeInTheDocument(); @@ -593,7 +636,7 @@ describe('DocumentSelectOrderStage', () => { renderSutWithExistingDocs(documents, existingDocs); - expect(screen.getByText('Existing Lloyd George record')).toBeInTheDocument(); + expect(screen.getByText(`Existing ${docConfig.displayName}`)).toBeInTheDocument(); }); it('existing Lloyd George record has position 1 and cannot be repositioned or removed', () => { @@ -613,7 +656,7 @@ describe('DocumentSelectOrderStage', () => { const rows = screen.getAllByRole('row'); const existingRow = rows.find((row) => - row.textContent?.includes('Existing Lloyd George record'), + row.textContent?.includes(`Existing ${docConfig.displayName}`), ); expect(existingRow).toBeInTheDocument(); @@ -817,6 +860,8 @@ function renderSutWithExistingDocs( setDocuments={mockSetDocuments} setMergedPdfBlob={mockSetMergedPdfBlob} existingDocuments={existingDocuments} + documentConfig={docConfig} + confirmFiles={mockConfirmFiles} /> , ); @@ -830,6 +875,8 @@ function renderSut(documents: UploadDocument[]): void { setDocuments={mockSetDocuments} setMergedPdfBlob={mockSetMergedPdfBlob} existingDocuments={[]} + documentConfig={docConfig} + confirmFiles={mockConfirmFiles} /> , ); diff --git a/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx b/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx index 146fa1d14..2ad0939e2 100644 --- a/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx @@ -21,13 +21,15 @@ import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/Pat import ErrorBox from '../../../layout/errorBox/ErrorBox'; import DocumentUploadLloydGeorgePreview from '../documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview'; import SpinnerButton from '../../../generic/spinnerButton/SpinnerButton'; -import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType'; +import { DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType'; type Props = { documents: UploadDocument[]; setDocuments: SetUploadDocuments; setMergedPdfBlob: Dispatch>; existingDocuments: UploadDocument[] | undefined; + documentConfig: DOCUMENT_TYPE_CONFIG; + confirmFiles: () => void; }; type FormData = { @@ -41,6 +43,8 @@ const DocumentSelectOrderStage = ({ setDocuments, setMergedPdfBlob, existingDocuments, + documentConfig, + confirmFiles, }: Readonly): JSX.Element => { const navigate = useEnhancedNavigate(); const journey = getJourney(); @@ -187,7 +191,7 @@ const DocumentSelectOrderStage = ({ const submitDocuments = (): void => { updateDocumentPositions(); if (documents.length === 1) { - navigate.withParams(routeChildren.DOCUMENT_UPLOAD_UPLOADING); + confirmFiles(); return; } @@ -209,8 +213,8 @@ const DocumentSelectOrderStage = ({ }) .filter((item) => item !== undefined); - const viewPdfFile = async (file: File): Promise => { - const blob = await getMergedPdfBlob([file]); + const viewPdfFile = async (document: UploadDocument): Promise => { + const blob = await getMergedPdfBlob([document.file]); const url = URL.createObjectURL(blob); window.open(url); @@ -246,12 +250,12 @@ const DocumentSelectOrderStage = ({ {!ableToReposition && position} - {document && ( + {document?.file.type === 'application/pdf' && ( {documents && documents.length > 0 && (
@@ -390,7 +418,10 @@ const DocumentSelectStage = ({ {documents && documents.length > 0 && ( <> - +
@@ -416,16 +447,18 @@ const DocumentSelectStage = ({ {documents.map(DocumentRow)}
- { - navigate.withParams(routeChildren.DOCUMENT_UPLOAD_REMOVE_ALL); - }} - > - Remove all files - + {multifile && ( + { + navigate.withParams(routeChildren.DOCUMENT_UPLOAD_REMOVE_ALL); + }} + > + Remove all files + + )} )}
diff --git a/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.scss b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.scss new file mode 100644 index 000000000..e414d2f76 --- /dev/null +++ b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.scss @@ -0,0 +1,33 @@ +.document-upload-complete-stage { + max-width: 711px; + + .toggle-button { + padding: 0; + margin: 0; + border-top: none; + width: fit-content; + + &:focus, + &:hover, + &:active { + .accordion-toggle-icon { + fill: black; + } + } + } + + .accordion-toggle { + display: flex !important; + align-items: center; + cursor: pointer; + margin-bottom: 10px; + text-align: center; + color: $color_nhsuk-blue; + user-select: none; + + .accordion-toggle-icon { + margin: 0 8px 0 -8px; + transform: rotate(90deg); + } + } +} diff --git a/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.test.tsx b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.test.tsx index a05775821..f0168385c 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.test.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.test.tsx @@ -3,7 +3,11 @@ import DocumentUploadCompleteStage from './DocumentUploadCompleteStage'; import userEvent from '@testing-library/user-event'; import { routes } from '../../../../types/generic/routes'; import { LinkProps, MemoryRouter } from 'react-router-dom'; -import { buildLgFile, buildPatientDetails } from '../../../../helpers/test/testBuilders'; +import { + buildDocumentConfig, + buildLgFile, + buildPatientDetails, +} from '../../../../helpers/test/testBuilders'; import { getFormattedDate } from '../../../../helpers/utils/formatDate'; import { formatNhsNumber } from '../../../../helpers/utils/formatNhsNumber'; import usePatient from '../../../../helpers/hooks/usePatient'; @@ -28,6 +32,7 @@ vi.mock('react-router-dom', async () => { URL.createObjectURL = vi.fn(); const patientDetails = buildPatientDetails(); +const docConfig = buildDocumentConfig(); describe('DocumentUploadCompleteStage', () => { let documents: UploadDocument[] = []; @@ -54,10 +59,6 @@ describe('DocumentUploadCompleteStage', () => { it('renders', async () => { renderApp(documents); - expect( - screen.getByText('You have successfully uploaded a digital Lloyd George record for:'), - ).toBeInTheDocument(); - const expectedFullName = getFormattedPatientFullName(patientDetails); expect(screen.getByTestId('patient-name').textContent).toEqual( 'Patient name: ' + expectedFullName, @@ -65,7 +66,7 @@ describe('DocumentUploadCompleteStage', () => { const expectedNhsNumber = formatNhsNumber(patientDetails.nhsNumber); expect(screen.getByTestId('nhs-number').textContent).toEqual( - 'NHS Number: ' + expectedNhsNumber, + 'NHS number: ' + expectedNhsNumber, ); const expectedDob = getFormattedDate(new Date(patientDetails.birthDate)); @@ -95,10 +96,67 @@ describe('DocumentUploadCompleteStage', () => { }); }); + it('should navigate to home if not all documents are in a finished state', async () => { + documents.push({ + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + id: '2', + file: buildLgFile(2), + attempts: 0, + state: DOCUMENT_UPLOAD_STATE.UPLOADING, + numPages: 3, + position: 2, + }); + + renderApp(documents); + + await waitFor(async () => { + expect(mockNavigate).toHaveBeenCalledWith(routes.HOME); + }); + }); + + it.each([ + { docState: DOCUMENT_UPLOAD_STATE.SUCCEEDED, expectedTitle: 'Upload complete' }, + { docState: DOCUMENT_UPLOAD_STATE.FAILED, expectedTitle: 'Upload partially complete' }, + ])('should set the page title based on upload success', async ({ docState, expectedTitle }) => { + documents = [ + { + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + id: '2', + file: buildLgFile(2), + attempts: 0, + state: docState, + numPages: 3, + position: 2, + }, + ]; + + renderApp(documents); + + expect(screen.getByTestId('page-title').textContent).toBe(expectedTitle); + }); + + it('should list failed documents', async () => { + documents.push({ + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + id: '2', + file: buildLgFile(2), + attempts: 0, + state: DOCUMENT_UPLOAD_STATE.FAILED, + numPages: 3, + position: 2, + }); + + renderApp(documents); + + await userEvent.click(screen.getByTestId('accordion-toggle-button')); + + expect(screen.getByText(documents[1].file.name)).toBeInTheDocument(); + }); + const renderApp = (documents: UploadDocument[]) => { render( - , + , , ); }; diff --git a/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx index 4d119a32e..0f2aeb9f6 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx @@ -1,4 +1,3 @@ -import { Button } from 'nhsuk-react-components'; import { routes } from '../../../../types/generic/routes'; import { Link, useNavigate } from 'react-router-dom'; import useTitle from '../../../../helpers/hooks/useTitle'; @@ -10,15 +9,18 @@ import { DOCUMENT_UPLOAD_STATE, UploadDocument, } from '../../../../types/pages/UploadDocumentsPage/types'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { allDocsHaveState } from '../../../../helpers/utils/uploadDocumentHelpers'; import { getJourney } from '../../../../helpers/utils/urlManipulations'; +import { DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType'; +import { Button, ChevronLeftIcon, ChevronRightIcon } from 'nhsuk-react-components'; type Props = { documents: UploadDocument[]; + documentConfig: DOCUMENT_TYPE_CONFIG; }; -const DocumentUploadCompleteStage = ({ documents }: Props): React.JSX.Element => { +const DocumentUploadCompleteStage = ({ documents, documentConfig }: Props): React.JSX.Element => { const navigate = useNavigate(); const patientDetails = usePatient(); const nhsNumber: string = patientDetails?.nhsNumber ?? ''; @@ -26,50 +28,90 @@ const DocumentUploadCompleteStage = ({ documents }: Props): React.JSX.Element => const dob: string = getFormattedDateFromString(patientDetails?.birthDate); const patientName = getFormattedPatientFullName(patientDetails); const journey = getJourney(); + const [showFiles, setShowFiles] = useState(false); - useTitle({ pageTitle: 'Record upload complete' }); + const failedDocuments = documents.filter((doc) => doc.state === DOCUMENT_UPLOAD_STATE.FAILED); + + const pageTitle = failedDocuments.length > 0 ? 'Upload partially complete' : 'Upload complete'; + useTitle({ pageTitle }); + + const docsAreInFinishedState = () => + allDocsHaveState(documents, [ + DOCUMENT_UPLOAD_STATE.SUCCEEDED, + DOCUMENT_UPLOAD_STATE.FAILED, + ]); useEffect(() => { - if (!allDocsHaveState(documents, DOCUMENT_UPLOAD_STATE.SUCCEEDED)) { + if (!docsAreInFinishedState()) { navigate(routes.HOME); } - }, [navigate]); + }, [navigate, documents]); - if (!allDocsHaveState(documents, DOCUMENT_UPLOAD_STATE.SUCCEEDED)) { + if (!docsAreInFinishedState()) { return <>; } return ( -
+
-

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} /> ) : ( - )} diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx index b310431dc..c0ee11fdf 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx @@ -3,10 +3,15 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import DocumentView from './DocumentView'; import usePatient from '../../../../helpers/hooks/usePatient'; import useTitle from '../../../../helpers/hooks/useTitle'; -import { DOCUMENT_TYPE, getDocumentTypeLabel } from '../../../../helpers/utils/documentType'; +import { + DOCUMENT_TYPE, + DOCUMENT_TYPE_CONFIG, + getConfigForDocType, + getDocumentTypeLabel, +} from '../../../../helpers/utils/documentType'; import { DocumentReference } from '../../../../types/pages/documentSearchResultsPage/types'; -import { routes } from '../../../../types/generic/routes'; -import { buildPatientDetails } from '../../../../helpers/test/testBuilders'; +import { routeChildren, routes } from '../../../../types/generic/routes'; +import { buildDocumentConfig, buildPatientDetails } from '../../../../helpers/test/testBuilders'; import userEvent from '@testing-library/user-event'; import { getFormattedDate } from '../../../../helpers/utils/formatDate'; import { lloydGeorgeRecordLinks } from '../../../../types/blocks/lloydGeorgeActions'; @@ -20,6 +25,13 @@ import useRole from '../../../../helpers/hooks/useRole'; vi.mock('../../../../helpers/hooks/usePatient'); vi.mock('../../../../helpers/hooks/useTitle'); vi.mock('../../../../helpers/hooks/useRole'); +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 { @@ -96,6 +108,7 @@ describe('DocumentView', () => { import.meta.env.VITE_ENVIRONMENT = 'vitest'; mockUsePatient.mockReturnValue(mockPatientDetails); mockUseRole.mockReturnValue(REPOSITORY_ROLE.GP_ADMIN); + vi.mocked(getConfigForDocType).mockReturnValue(buildDocumentConfig()); // Mock fullscreen API Object.defineProperty(document, 'fullscreenEnabled', { @@ -187,50 +200,16 @@ describe('DocumentView', () => { expect(screen.getByText(/Last updated:/)).toBeInTheDocument(); }); - it.each( - Array.from(Object.values(DOCUMENT_TYPE)).filter((type) => type !== DOCUMENT_TYPE.ALL), - )('displays document type label in record card when doc type is %s', (documentType) => { - renderComponent({ - ...mockDocumentReference, - documentSnomedCodeType: documentType, - }); + it('displays document type label in record card', () => { + renderComponent(); expect(screen.getByTestId('record-card-container')).toHaveTextContent( - getDocumentTypeLabel(documentType), + buildDocumentConfig().content.viewDocumentTitle as string, ); }); }); describe('Add Files functionality', () => { - it('shows add files section for Lloyd George documents when not in fullscreen', () => { - renderComponent(); - - expect(screen.getByTestId('add-files-btn')).toBeInTheDocument(); - }); - - it('does not show add files section when in fullscreen', async () => { - renderComponent(); - - await screen.findByTitle(EMBEDDED_PDF_VIEWER_TITLE); - await userEvent.click(screen.getByText('View in full screen')); - - // Simulate the browser entering fullscreen - simulateFullscreenChange(true); - - expect(screen.queryByText('Add Files')).not.toBeInTheDocument(); - }); - - it('does not show add files section for non-Lloyd George documents', () => { - const nonLGDocument = { - ...mockDocumentReference, - documentSnomedCodeType: DOCUMENT_TYPE.EHR, - }; - - renderComponent(nonLGDocument); - - expect(screen.queryByText('Add Files')).not.toBeInTheDocument(); - }); - it('navigates to upload page when add files is clicked', async () => { renderComponent(); @@ -240,7 +219,7 @@ describe('DocumentView', () => { await waitFor(() => { expect(mockUseNavigate).toHaveBeenCalledWith( expect.objectContaining({ - pathname: routes.DOCUMENT_UPLOAD, + pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_FILES, }), expect.objectContaining({ state: expect.objectContaining({ @@ -268,6 +247,77 @@ describe('DocumentView', () => { expect(mockUseNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR); }); }); + + it.each([ + { + canBeUpdated: true, + role: REPOSITORY_ROLE.GP_ADMIN, + deceased: false, + fullscreen: false, + addBtnVisible: true, + }, + { + canBeUpdated: true, + role: REPOSITORY_ROLE.GP_CLINICAL, + deceased: false, + fullscreen: false, + addBtnVisible: true, + }, + { + canBeUpdated: true, + role: REPOSITORY_ROLE.PCSE, + deceased: false, + fullscreen: false, + addBtnVisible: false, + }, + { + canBeUpdated: true, + role: REPOSITORY_ROLE.GP_ADMIN, + deceased: true, + fullscreen: false, + addBtnVisible: false, + }, + { + canBeUpdated: false, + role: REPOSITORY_ROLE.GP_ADMIN, + deceased: false, + fullscreen: false, + addBtnVisible: false, + }, + { + canBeUpdated: false, + role: REPOSITORY_ROLE.GP_ADMIN, + deceased: false, + fullscreen: true, + addBtnVisible: false, + }, + ])( + 'displays add button when %s', + async ({ canBeUpdated, role, deceased, fullscreen, addBtnVisible }) => { + vi.mocked(getConfigForDocType).mockReturnValue( + buildDocumentConfig({ canBeUpdated }), + ); + + mockUseRole.mockReturnValue(role); + mockUsePatient.mockReturnValue(buildPatientDetails({ deceased })); + + renderComponent(); + + if (fullscreen) { + await screen.findByTitle(EMBEDDED_PDF_VIEWER_TITLE); + await userEvent.click(screen.getByText('View in full screen')); + + // Simulate the browser entering fullscreen + simulateFullscreenChange(true); + } + + await waitFor(() => { + expect(screen.queryAllByTestId('add-files-btn')).toHaveLength( + addBtnVisible ? 1 : 0, + ); + }); + }, + ); }); describe('Document actions', () => { diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx index 39d02de23..9f63905e0 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx @@ -1,7 +1,7 @@ import { routeChildren, routes } from '../../../../types/generic/routes'; import useTitle from '../../../../helpers/hooks/useTitle'; import { useSessionContext } from '../../../../providers/sessionProvider/SessionProvider'; -import { DOCUMENT_TYPE, getDocumentTypeLabel } from '../../../../helpers/utils/documentType'; +import { DOCUMENT_TYPE, getConfigForDocType } from '../../../../helpers/utils/documentType'; import { getFormattedDate } from '../../../../helpers/utils/formatDate'; import { DocumentReference } from '../../../../types/pages/documentSearchResultsPage/types'; import { @@ -35,6 +35,9 @@ const DocumentView = ({ const navigate = useNavigate(); const showMenu = role === REPOSITORY_ROLE.GP_ADMIN && !session.isFullscreen; const patientDetails = usePatient(); + const documentConfig = getConfigForDocType( + documentReference?.documentSnomedCodeType ?? DOCUMENT_TYPE.LLOYD_GEORGE, + ); const pageHeader = 'Lloyd George records'; useTitle({ pageTitle: pageHeader }); @@ -150,7 +153,7 @@ const DocumentView = ({ const blob = await response.blob(); const to: To = { - pathname: routes.DOCUMENT_UPLOAD, + pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_FILES, search: createSearchParams({ journey: 'update' }).toString(), }; const options: NavigateOptions = { @@ -165,7 +168,7 @@ const DocumentView = ({ const getRecordCard = (): React.JSX.Element => { const card = ( ); + return session.isFullscreen ? ( card ) : ( @@ -188,6 +192,12 @@ const DocumentView = ({ ); }; + const canAddFiles = + documentConfig.canBeUpdated && + documentReference.url && + !patientDetails?.deceased && + (role === REPOSITORY_ROLE.GP_ADMIN || role === REPOSITORY_ROLE.GP_CLINICAL); + return (
{session.isFullscreen && ( @@ -241,19 +251,25 @@ const DocumentView = ({ /> )} - {!session.isFullscreen && - documentReference.documentSnomedCodeType === DOCUMENT_TYPE.LLOYD_GEORGE && ( - <> -

Add Files

-

You can add more files to this patient's record.

- - - )} + {!session.isFullscreen && canAddFiles && ( + <> +

Add Files

+

You can add more files to this patient's record.

+ + + )}
- {getRecordCard()} + {documentReference.url + ? getRecordCard() + : ( +

+ This document is currently being uploaded, please try again in a few minutes. +

+ ) + } ); diff --git a/app/src/config/documentTypesConfig.json b/app/src/config/documentTypesConfig.json index 62a058160..a2632482e 100644 --- a/app/src/config/documentTypesConfig.json +++ b/app/src/config/documentTypesConfig.json @@ -4,13 +4,13 @@ "snomed_code": "16521000000101", "config_name": "scannedPaperNotesConfig", "content": { - "upload_title": "Scanned Lloyd George notes", + "upload_title": "Scanned paper notes notes", "upload_description": "Upload and add files to a scanned paper Lloyd George record." } }, { "name": "Electronic Health Record", - "snomed_code": "16521000000102", + "snomed_code": "717301000000104", "config_name": "electronicHealthRecordConfig", "content": { "upload_title": "Electronic health record (EHR) summary", @@ -19,7 +19,7 @@ }, { "name": "Electronic Health Record Attachments", - "snomed_code": "16521000000103", + "snomed_code": "24511000000107", "config_name": "electronicHealthRecordAttachmentsConfig", "content": { "upload_title": "Attachments to an electronic health record", diff --git a/app/src/config/electronicHealthRecordAttachmentsConfig.json b/app/src/config/electronicHealthRecordAttachmentsConfig.json index 9c4fbe2e4..952de8ec3 100644 --- a/app/src/config/electronicHealthRecordAttachmentsConfig.json +++ b/app/src/config/electronicHealthRecordAttachmentsConfig.json @@ -1,13 +1,31 @@ { - "snomedCode": "16521000000103", - "displayName": "Electronic Health Record Attachments", + "snomedCode": "24511000000107", + "displayName": "electronic health record attachments", "canBeUpdated": false, - "associatedSnomed": "", - "multifileUpload": false, + "associatedSnomed": "717301000000104", + "multifileUpload": true, "multifileZipped": true, + "zippedFilename": "EHR_Attachments", "multifileReview": false, "canBeDiscarded": true, "stitched": false, + "singleDocumentOnly": false, "acceptedFileTypes": [], - "content": {} + "content": { + "viewDocumentTitle": "Electronic health record attachments", + "addFilesSelectTitle": "", + "uploadFilesSelectTitle": "Choose electronic health record attachment files to upload", + "uploadFilesBulletPoints": [ + "Check your files open correctly", + "Remove any passwords from files", + "If there is a problem with your any of your files during upload, you'll need to resolve these before continuing" + ], + "chooseFilesMessage": "Choose files to upload", + "chooseFilesButtonLabel": "Choose files", + "chooseFilesWarningText": "", + "confirmFilesTitle": "Check files are for the correct patient", + "beforeYouUploadTitle": "Before you upload", + "previewUploadTitle": "Preview electronic health record attachment", + "uploadFilesExtraParagraph": "" + } } \ No newline at end of file diff --git a/app/src/config/electronicHealthRecordConfig.json b/app/src/config/electronicHealthRecordConfig.json index 22538e40d..909a0377a 100644 --- a/app/src/config/electronicHealthRecordConfig.json +++ b/app/src/config/electronicHealthRecordConfig.json @@ -1,13 +1,33 @@ { - "snomedCode": "16521000000102", - "displayName": "Electronic Health Record", + "snomedCode": "717301000000104", + "displayName": "electronic health record", "canBeUpdated": false, - "associatedSnomed": "16521000000103", + "associatedSnomed": "24511000000107", "multifileUpload": false, "multifileZipped": false, "multifileReview": false, "canBeDiscarded": true, "stitched": false, - "acceptedFileTypes": [], - "content": {} + "singleDocumentOnly": false, + "acceptedFileTypes": [ + "PDF" + ], + "content": { + "viewDocumentTitle": "Electronic health record", + "addFilesSelectTitle": "", + "uploadFilesSelectTitle": "Choose an electronic health record (EHR) to upload", + "uploadFilesBulletPoints": [ + "You can only upload a single PDF file", + "Check your file opens correctly", + "Remove any password protection from the file", + "If there is a problem with your file during upload, you'll need to resolve it before continuing" + ], + "chooseFilesMessage": "Choose PDF file to upload", + "chooseFilesButtonLabel": "Choose PDF file", + "chooseFilesWarningText": "EHR warning text", + "confirmFilesTitle": "Check file is for the correct patient", + "beforeYouUploadTitle": "Before you upload", + "previewUploadTitle": "Preview this electronic health record", + "uploadFilesExtraParagraph": "" + } } \ No newline at end of file diff --git a/app/src/config/lloydGeorgeConfig.json b/app/src/config/lloydGeorgeConfig.json index cd2e1c908..08d3a32a9 100644 --- a/app/src/config/lloydGeorgeConfig.json +++ b/app/src/config/lloydGeorgeConfig.json @@ -1,6 +1,7 @@ { "snomedCode": "16521000000101", - "displayName": "Scanned Paper Notes", + "displayName": "scanned paper notes", + "filenameOverride": "Scanned paper notes.pdf", "canBeUpdated": true, "associatedSnomed": "", "multifileUpload": true, @@ -8,8 +9,27 @@ "multifileReview": true, "canBeDiscarded": true, "stitched": true, + "stitchedFilenamePrefix": "1of1_Lloyd_George_Record", + "singleDocumentOnly": true, "acceptedFileTypes": [ - ".pdf" + "PDF" ], - "content": {} + "content": { + "viewDocumentTitle": "Scanned paper notes", + "addFilesSelectTitle": "Add scanned paper notes files to this record", + "uploadFilesSelectTitle": "Choose scanned paper notes files to upload", + "uploadFilesBulletPoints": [ + "You can only upload PDF files", + "Check your files open correctly", + "Remove any passwords from files", + "If there is a problem with your files during upload, you'll need to resolve these before continuing" + ], + "chooseFilesMessage": "Choose PDF files to upload", + "chooseFilesButtonLabel": "Choose PDF files", + "chooseFilesWarningText": "", + "confirmFilesTitle": "Check your files before uploading", + "beforeYouUploadTitle": "Before you upload", + "previewUploadTitle": "Preview this scanned paper notes record", + "uploadFilesExtraParagraph": "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 16521000000101." + } } \ No newline at end of file diff --git a/app/src/config/rejectedFileTypes.json b/app/src/config/rejectedFileTypes.json new file mode 100644 index 000000000..ba34252e9 --- /dev/null +++ b/app/src/config/rejectedFileTypes.json @@ -0,0 +1,51 @@ +[ + "ACTION", + "APK", + "APP", + "BAT", + "BIN", + "CAB", + "CMD", + "COM", + "COMMAND", + "CPL", + "CSH", + "EX_", + "EXE", + "GADGET", + "INF1", + "INS", + "INX", + "IPA", + "ISU", + "JOB", + "JSE", + "KSH", + "LNK", + "MSC", + "MSI", + "MSP", + "MST", + "OSX", + "OUT", + "PAF", + "PIF", + "PRG", + "PS1", + "REG", + "RGS", + "RUN", + "SCR", + "SCT", + "SHB", + "SHS", + "U3P", + "VB", + "VBE", + "VBS", + "VBSCRIPT", + "WORKFLOW", + "WS", + "WSF", + "WSH" +] \ No newline at end of file diff --git a/app/src/helpers/requests/documentReview.test.ts b/app/src/helpers/requests/documentReview.test.ts index 4965e13c0..736b5a88b 100644 --- a/app/src/helpers/requests/documentReview.test.ts +++ b/app/src/helpers/requests/documentReview.test.ts @@ -13,7 +13,7 @@ const mockedAxios = axios as Mocked; describe('documentReview', () => { const mockAuthHeaders: AuthHeaders = { authorization: 'Bearer token', - "Content-Type": 'string', + 'Content-Type': 'string', }; const mockDocument: UploadDocument = { @@ -59,7 +59,7 @@ describe('documentReview', () => { params: { patientId: mockArgs.nhsNumber, }, - } + }, ); expect(result).toEqual(mockResponse.data); }); @@ -86,7 +86,7 @@ describe('documentReview', () => { params: { patientId: mockArgs.nhsNumber, }, - } + }, ); expect(result).toEqual(mockResponse.data); }); @@ -98,4 +98,4 @@ describe('documentReview', () => { await expect(getDocumentReviewStatus(mockArgs)).rejects.toThrow('Status fetch failed'); }); }); -}); \ No newline at end of file +}); diff --git a/app/src/helpers/requests/documentReview.ts b/app/src/helpers/requests/documentReview.ts index f41e1c9f4..588d72c2a 100644 --- a/app/src/helpers/requests/documentReview.ts +++ b/app/src/helpers/requests/documentReview.ts @@ -27,14 +27,18 @@ export const uploadDocumentForReview = async ({ const gatewayUrl = baseUrl + endpoints.DOCUMENT_REVIEW; try { - const { data } = await axios.post(gatewayUrl, JSON.stringify(requestBody), { - headers: { - ...baseHeaders, - }, - params: { - patientId: nhsNumber, + const { data } = await axios.post( + gatewayUrl, + JSON.stringify(requestBody), + { + headers: { + ...baseHeaders, + }, + params: { + patientId: nhsNumber, + }, }, - }); + ); return data; } catch (e) { diff --git a/app/src/helpers/requests/uploadDocument.test.ts b/app/src/helpers/requests/uploadDocument.test.ts index 2ba8c9b5c..b764d3c01 100644 --- a/app/src/helpers/requests/uploadDocument.test.ts +++ b/app/src/helpers/requests/uploadDocument.test.ts @@ -12,13 +12,13 @@ import { import uploadDocuments, { getDocumentStatus, uploadDocumentToS3, - generateFileName, + generateStitchedFileName, } from './uploadDocuments'; import { describe, expect, it, Mocked, vi, beforeEach } from 'vitest'; import { DocumentStatusResult } from '../../types/generic/uploadResult'; import { endpoints } from '../../types/generic/endpoints'; import { v4 as uuidv4 } from 'uuid'; -import { DOCUMENT_TYPE } from '../utils/documentType'; +import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../utils/documentType'; vi.mock('axios'); @@ -28,1121 +28,1114 @@ const nhsNumber = '9000000009'; const baseUrl = 'http://localhost/test'; const baseHeaders = { 'Content-Type': 'application/json', test: 'test' }; -describe('uploadDocuments', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should use POST request when documentReferenceId is undefined', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const mockUploadSession = buildUploadSession(documents); - - mockedAxios.post.mockResolvedValue({ - status: 200, - data: mockUploadSession, +describe('Upload Document Requests', () => { + describe('uploadDocuments', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - const result = await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId: undefined, - }); - - expect(mockedAxios.post).toHaveBeenCalledTimes(1); - expect(mockedAxios.post).toHaveBeenCalledWith( - baseUrl + endpoints.DOCUMENT_REFERENCE, - expect.any(String), - { - headers: baseHeaders, - params: { - patientId: nhsNumber, - }, - }, - ); - expect(result).toEqual(mockUploadSession); - }); - - it('should use PUT request when documentReferenceId is provided', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const mockUploadSession = buildUploadSession(documents); - const documentReferenceId = 'test-ref-id-123'; - - mockedAxios.put.mockResolvedValue({ - status: 200, - data: mockUploadSession, - }); + it('should use POST request when documentReferenceId is undefined', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - const result = await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId, - }); + const mockUploadSession = buildUploadSession(documents); - const requestBody = JSON.parse(mockedAxios.put.mock.calls[0][1] as string); + mockedAxios.post.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); - expect(mockedAxios.put).toHaveBeenCalledTimes(1); - expect(mockedAxios.put).toHaveBeenCalledWith( - baseUrl + endpoints.DOCUMENT_REFERENCE + `/${documentReferenceId}`, - expect.any(String), - { - headers: baseHeaders, - params: { - patientId: nhsNumber, - }, - }, - ); + const result = await uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId: undefined, + }); - expect(requestBody).toMatchObject({ - resourceType: 'DocumentReference', - subject: { - identifier: { - system: 'https://fhir.nhs.uk/Id/nhs-number', - value: nhsNumber, - }, - }, - type: { - coding: [ - { - system: 'http://snomed.info/sct', - code: '22151000087106', - }, - ], - }, - content: [ + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith( + baseUrl + endpoints.DOCUMENT_REFERENCE, + expect.any(String), { - attachment: expect.objectContaining({ - fileName: documents[0].file.name, - contentType: documents[0].file.type, - docType: documents[0].docType, - clientId: documents[0].id, - versionId: documents[0].versionId, - }), + headers: baseHeaders, + params: { + patientId: nhsNumber, + }, }, - ], + ); + expect(result).toEqual(mockUploadSession); }); - expect(result).toEqual(mockUploadSession); - }); - - it('should send correct request body structure', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; + it('should use PUT request when documentReferenceId is provided', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - const mockUploadSession = buildUploadSession(documents); + const mockUploadSession = buildUploadSession(documents); + const documentReferenceId = 'test-ref-id-123'; - mockedAxios.post.mockResolvedValue({ - status: 200, - data: mockUploadSession, - }); + mockedAxios.put.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); - await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId: undefined, - }); + const result = await uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId, + }); - const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); + const requestBody = JSON.parse(mockedAxios.put.mock.calls[0][1] as string); - expect(requestBody).toMatchObject({ - resourceType: 'DocumentReference', - subject: { - identifier: { - system: 'https://fhir.nhs.uk/Id/nhs-number', - value: nhsNumber, + expect(mockedAxios.put).toHaveBeenCalledTimes(1); + expect(mockedAxios.put).toHaveBeenCalledWith( + baseUrl + endpoints.DOCUMENT_REFERENCE + `/${documentReferenceId}`, + expect.any(String), + { + headers: baseHeaders, + params: { + patientId: nhsNumber, + }, + }, + ); + + expect(requestBody).toMatchObject({ + resourceType: 'DocumentReference', + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: nhsNumber, + }, }, - }, - type: { - coding: [ + type: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '22151000087106', + }, + ], + }, + content: [ { - system: 'http://snomed.info/sct', - code: '22151000087106', + attachment: expect.objectContaining({ + fileName: documents[0].file.name, + contentType: documents[0].file.type, + docType: documents[0].docType, + clientId: documents[0].id, + versionId: documents[0].versionId, + }), }, ], - }, - content: [ - { - attachment: expect.arrayContaining([ - expect.objectContaining({ - fileName: expect.any(String), - contentType: expect.any(String), - docType: expect.any(String), - clientId: expect.any(String), - }), - ]), - }, - ], - }); - expect(requestBody.created).toBeDefined(); - }); + }); - it('should handle multiple documents in request body', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - buildDocument( - buildLgFile(2), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const mockUploadSession = buildUploadSession(documents); - - mockedAxios.post.mockResolvedValue({ - status: 200, - data: mockUploadSession, + expect(result).toEqual(mockUploadSession); }); - await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId: undefined, - }); + it('should send correct request body structure', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); + const mockUploadSession = buildUploadSession(documents); - expect(requestBody.content[0].attachment).toHaveLength(2); - expect(requestBody.content[0].attachment[0]).toMatchObject({ - fileName: documents[0].file.name, - contentType: documents[0].file.type, - docType: documents[0].docType, - clientId: documents[0].id, - }); - expect(requestBody.content[0].attachment[1]).toMatchObject({ - fileName: documents[1].file.name, - contentType: documents[1].file.type, - docType: documents[1].docType, - clientId: documents[1].id, - }); - }); + mockedAxios.post.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); - it('should throw error when POST request fails', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const mockError = { - response: { - status: 500, - data: { message: 'Internal server error' }, - }, - }; - - mockedAxios.post.mockRejectedValue(mockError); - - await expect( - uploadDocuments({ + await uploadDocuments({ nhsNumber, documents, baseUrl, baseHeaders, documentReferenceId: undefined, - }), - ).rejects.toEqual(mockError); - }); + }); - it('should throw error when PUT request fails', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const documentReferenceId = 'test-ref-id-123'; - const mockError = { - response: { - status: 404, - data: { message: 'Document reference not found' }, - }, - }; + const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); - mockedAxios.put.mockRejectedValue(mockError); + expect(requestBody).toMatchObject({ + resourceType: 'DocumentReference', + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: nhsNumber, + }, + }, + type: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '22151000087106', + }, + ], + }, + content: [ + { + attachment: expect.arrayContaining([ + expect.objectContaining({ + fileName: expect.any(String), + contentType: expect.any(String), + docType: expect.any(String), + clientId: expect.any(String), + }), + ]), + }, + ], + }); + expect(requestBody.created).toBeDefined(); + }); - await expect( - uploadDocuments({ + it('should handle multiple documents in request body', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + buildDocument( + buildLgFile(2), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + const mockUploadSession = buildUploadSession(documents); + + mockedAxios.post.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); + + await uploadDocuments({ nhsNumber, documents, baseUrl, baseHeaders, - documentReferenceId, - }), - ).rejects.toEqual(mockError); - }); - - it('should handle empty string documentReferenceId as falsy value', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const mockUploadSession = buildUploadSession(documents); - - mockedAxios.post.mockResolvedValue({ - status: 200, - data: mockUploadSession, - }); - - await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId: undefined, + documentReferenceId: undefined, + }); + + const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); + + expect(requestBody.content[0].attachment).toHaveLength(2); + expect(requestBody.content[0].attachment[0]).toMatchObject({ + fileName: documents[0].file.name, + contentType: documents[0].file.type, + docType: documents[0].docType, + clientId: documents[0].id, + }); + expect(requestBody.content[0].attachment[1]).toMatchObject({ + fileName: documents[1].file.name, + contentType: documents[1].file.type, + docType: documents[1].docType, + clientId: documents[1].id, + }); }); - expect(mockedAxios.post).toHaveBeenCalledWith( - baseUrl + endpoints.DOCUMENT_REFERENCE, - expect.any(String), - expect.any(Object), - ); - }); + it('should throw error when POST request fails', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + const mockError = { + response: { + status: 500, + data: { message: 'Internal server error' }, + }, + }; - it('should include timestamp in created field', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const mockUploadSession = buildUploadSession(documents); - const beforeTime = new Date().toISOString(); - - mockedAxios.post.mockResolvedValue({ - status: 200, - data: mockUploadSession, + mockedAxios.post.mockRejectedValue(mockError); + + await expect( + uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId: undefined, + }), + ).rejects.toEqual(mockError); }); - await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId: undefined, - }); + it('should throw error when PUT request fails', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + const documentReferenceId = 'test-ref-id-123'; + const mockError = { + response: { + status: 404, + data: { message: 'Document reference not found' }, + }, + }; - const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); - const afterTime = new Date().toISOString(); + mockedAxios.put.mockRejectedValue(mockError); + + await expect( + uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId, + }), + ).rejects.toEqual(mockError); + }); - expect(requestBody.created).toBeDefined(); - expect(new Date(requestBody.created).getTime()).toBeGreaterThanOrEqual( - new Date(beforeTime).getTime(), - ); - expect(new Date(requestBody.created).getTime()).toBeLessThanOrEqual( - new Date(afterTime).getTime(), - ); - }); + it('should handle empty string documentReferenceId as falsy value', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - it('should handle 401 unauthorized error', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const mockError = { - response: { - status: 401, - data: { message: 'Unauthorized' }, - }, - }; + const mockUploadSession = buildUploadSession(documents); - mockedAxios.post.mockRejectedValue(mockError); + mockedAxios.post.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); - await expect( - uploadDocuments({ + await uploadDocuments({ nhsNumber, documents, baseUrl, baseHeaders, documentReferenceId: undefined, - }), - ).rejects.toEqual(mockError); - }); + }); - it('should handle 403 forbidden error', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const documentReferenceId = 'test-ref-id-123'; - const mockError = { - response: { - status: 403, - data: { message: 'Forbidden' }, - }, - }; + expect(mockedAxios.post).toHaveBeenCalledWith( + baseUrl + endpoints.DOCUMENT_REFERENCE, + expect.any(String), + expect.any(Object), + ); + }); + + it('should include timestamp in created field', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - mockedAxios.put.mockRejectedValue(mockError); + const mockUploadSession = buildUploadSession(documents); + const beforeTime = new Date().toISOString(); - await expect( - uploadDocuments({ + mockedAxios.post.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); + + await uploadDocuments({ nhsNumber, documents, baseUrl, baseHeaders, - documentReferenceId, - }), - ).rejects.toEqual(mockError); - }); - - it('should correctly serialize request body as JSON string', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; + documentReferenceId: undefined, + }); + + const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); + const afterTime = new Date().toISOString(); + + expect(requestBody.created).toBeDefined(); + expect(new Date(requestBody.created).getTime()).toBeGreaterThanOrEqual( + new Date(beforeTime).getTime(), + ); + expect(new Date(requestBody.created).getTime()).toBeLessThanOrEqual( + new Date(afterTime).getTime(), + ); + }); - const mockUploadSession = buildUploadSession(documents); + it('should handle 401 unauthorized error', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + const mockError = { + response: { + status: 401, + data: { message: 'Unauthorized' }, + }, + }; - mockedAxios.post.mockResolvedValue({ - status: 200, - data: mockUploadSession, + mockedAxios.post.mockRejectedValue(mockError); + + await expect( + uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId: undefined, + }), + ).rejects.toEqual(mockError); }); - await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId: undefined, + it('should handle 403 forbidden error', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + const documentReferenceId = 'test-ref-id-123'; + const mockError = { + response: { + status: 403, + data: { message: 'Forbidden' }, + }, + }; + + mockedAxios.put.mockRejectedValue(mockError); + + await expect( + uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId, + }), + ).rejects.toEqual(mockError); }); - const requestBodyString = mockedAxios.post.mock.calls[0][1] as string; - expect(() => JSON.parse(requestBodyString)).not.toThrow(); - expect(typeof requestBodyString).toBe('string'); - }); + it('should correctly serialize request body as JSON string', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - it('should include all document properties in attachment', async () => { - const testFile = buildLgFile(1); - const documents = [ - buildDocument(testFile, DOCUMENT_UPLOAD_STATE.SELECTED, DOCUMENT_TYPE.LLOYD_GEORGE), - ]; + const mockUploadSession = buildUploadSession(documents); - const mockUploadSession = buildUploadSession(documents); + mockedAxios.post.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); - mockedAxios.post.mockResolvedValue({ - status: 200, - data: mockUploadSession, - }); + await uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId: undefined, + }); - await uploadDocuments({ - nhsNumber, - documents, - baseUrl, - baseHeaders, - documentReferenceId: undefined, + const requestBodyString = mockedAxios.post.mock.calls[0][1] as string; + expect(() => JSON.parse(requestBodyString)).not.toThrow(); + expect(typeof requestBodyString).toBe('string'); }); - const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); - const attachment = requestBody.content[0].attachment[0]; + it('should include all document properties in attachment', async () => { + const testFile = buildLgFile(1); + const documents = [ + buildDocument(testFile, DOCUMENT_UPLOAD_STATE.SELECTED, DOCUMENT_TYPE.LLOYD_GEORGE), + ]; - expect(attachment).toEqual({ - fileName: documents[0].file.name, - contentType: documents[0].file.type, - docType: documents[0].docType, - clientId: documents[0].id, - versionId: documents[0].versionId, - }); - }); -}); - -describe('uploadDocumentToS3', () => { - const testFile = buildLgFile(1); - const testDocument = buildDocument( - testFile, - DOCUMENT_UPLOAD_STATE.SELECTED, - DOCUMENT_TYPE.LLOYD_GEORGE, - ); - const mockUploadSession = buildUploadSession([testDocument]); - const mockSetDocuments = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); + const mockUploadSession = buildUploadSession(documents); - it('make POST request to s3 bucket', async () => { - mockedAxios.post.mockResolvedValue({ status: 200 }); + mockedAxios.post.mockResolvedValue({ + status: 200, + data: mockUploadSession, + }); - await uploadDocumentToS3({ - setDocuments: mockSetDocuments, - uploadSession: mockUploadSession, - document: testDocument, + await uploadDocuments({ + nhsNumber, + documents, + baseUrl, + baseHeaders, + documentReferenceId: undefined, + }); + + const requestBody = JSON.parse(mockedAxios.post.mock.calls[0][1] as string); + const attachment = requestBody.content[0].attachment[0]; + + expect(attachment).toEqual({ + fileName: documents[0].file.name, + contentType: documents[0].file.type, + docType: documents[0].docType, + clientId: documents[0].id, + versionId: documents[0].versionId, + }); }); - - expect(mockedAxios.post).toHaveBeenCalledTimes(1); }); - it('should upload document with correct FormData structure', async () => { - mockedAxios.post.mockResolvedValue({ status: 200 }); + describe('uploadDocumentToS3', () => { + const testFile = buildLgFile(1); + const testDocument = buildDocument( + testFile, + DOCUMENT_UPLOAD_STATE.SELECTED, + DOCUMENT_TYPE.LLOYD_GEORGE, + ); + const mockUploadSession = buildUploadSession([testDocument]); + const mockSetDocuments = vi.fn(); - await uploadDocumentToS3({ - setDocuments: mockSetDocuments, - uploadSession: mockUploadSession, - document: testDocument, + beforeEach(() => { + vi.clearAllMocks(); }); - const documentMetadata = mockUploadSession[testDocument.id]; - const [s3Url, formData] = mockedAxios.post.mock.calls[0]; - - expect(s3Url).toBe(documentMetadata.url); - expect(formData).toBeInstanceOf(FormData); - }); - - it('should throw error when S3 upload fails', async () => { - const mockError = { - response: { - status: 403, - data: { message: 'Access denied' }, - }, - }; - - mockedAxios.post.mockRejectedValue(mockError); + it('make POST request to s3 bucket', async () => { + mockedAxios.post.mockResolvedValue({ status: 200 }); - await expect( - uploadDocumentToS3({ + await uploadDocumentToS3({ setDocuments: mockSetDocuments, uploadSession: mockUploadSession, document: testDocument, - }), - ).rejects.toEqual(mockError); - }); + }); - it('should handle network error during upload', async () => { - const networkError = new Error('Network error'); - mockedAxios.post.mockRejectedValue(networkError); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('should upload File', async () => { + mockedAxios.post.mockResolvedValue({ status: 200 }); - await expect( - uploadDocumentToS3({ + await uploadDocumentToS3({ setDocuments: mockSetDocuments, uploadSession: mockUploadSession, document: testDocument, - }), - ).rejects.toThrow('Network error'); - }); + }); - it('should append all FormData fields from upload session', async () => { - mockedAxios.post.mockResolvedValue({ status: 200 }); + const documentMetadata = mockUploadSession[testDocument.id]; + const [s3Url, file] = mockedAxios.post.mock.calls[0]; - await uploadDocumentToS3({ - setDocuments: mockSetDocuments, - uploadSession: mockUploadSession, - document: testDocument, + expect(s3Url).toBe(documentMetadata.url); + expect(file).toBeInstanceOf(File); }); - const [, formData] = mockedAxios.post.mock.calls[0]; - expect(formData).toBeInstanceOf(FormData); - }); + it('should throw error when S3 upload fails', async () => { + const mockError = { + response: { + status: 403, + data: { message: 'Access denied' }, + }, + }; - it('should return axios response on successful upload', async () => { - const mockResponse = { status: 200, data: { success: true } }; - mockedAxios.post.mockResolvedValue(mockResponse); + mockedAxios.post.mockRejectedValue(mockError); - const result = await uploadDocumentToS3({ - setDocuments: mockSetDocuments, - uploadSession: mockUploadSession, - document: testDocument, + await expect( + uploadDocumentToS3({ + setDocuments: mockSetDocuments, + uploadSession: mockUploadSession, + document: testDocument, + }), + ).rejects.toEqual(mockError); }); - expect(result).toEqual(mockResponse); - }); + it('should handle network error during upload', async () => { + const networkError = new Error('Network error'); + mockedAxios.post.mockRejectedValue(networkError); + + await expect( + uploadDocumentToS3({ + setDocuments: mockSetDocuments, + uploadSession: mockUploadSession, + document: testDocument, + }), + ).rejects.toThrow('Network error'); + }); - it('should handle timeout error during upload', async () => { - const timeoutError = { - code: 'ECONNABORTED', - message: 'timeout of 30000ms exceeded', - }; - mockedAxios.post.mockRejectedValue(timeoutError); + it('should return axios response on successful upload', async () => { + const mockResponse = { status: 200, data: { success: true } }; + mockedAxios.post.mockResolvedValue(mockResponse); - await expect( - uploadDocumentToS3({ + const result = await uploadDocumentToS3({ setDocuments: mockSetDocuments, uploadSession: mockUploadSession, document: testDocument, - }), - ).rejects.toEqual(timeoutError); - }); -}); + }); -describe('generateFileName', () => { - it('generates correct filename with valid patient details', () => { - const patientDetails = buildPatientDetails({ - givenName: ['John', 'Michael'], - familyName: 'Smith', - nhsNumber: '1234567890', - birthDate: '1990-05-15', + expect(result).toEqual(mockResponse); }); - const result = generateFileName(patientDetails); - - expect(result).toBe( - '1of1_Lloyd_George_Record_[John Michael SMITH]_[1234567890]_[15-05-1990].pdf', - ); + it('should handle timeout error during upload', async () => { + const timeoutError = { + code: 'ECONNABORTED', + message: 'timeout of 30000ms exceeded', + }; + mockedAxios.post.mockRejectedValue(timeoutError); + + await expect( + uploadDocumentToS3({ + setDocuments: mockSetDocuments, + uploadSession: mockUploadSession, + document: testDocument, + }), + ).rejects.toEqual(timeoutError); + }); }); - it('generates correct filename with single given name', () => { - const patientDetails = buildPatientDetails({ - givenName: ['Jane'], - familyName: 'Doe', - nhsNumber: '0987654321', - birthDate: '1985-12-25', - }); + describe('generateStitchedFileName', () => { + const docConfig = { + stitchedFilenamePrefix: '1of1_Lloyd_George_Record', + }; - const result = generateFileName(patientDetails); + it('generates correct filename with valid patient details', () => { + const patientDetails = buildPatientDetails({ + givenName: ['John', 'Michael'], + familyName: 'Smith', + nhsNumber: '1234567890', + birthDate: '1990-05-15', + }); - expect(result).toBe('1of1_Lloyd_George_Record_[Jane DOE]_[0987654321]_[25-12-1985].pdf'); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('handles special characters in given name by replacing them with dashes', () => { - const patientDetails = buildPatientDetails({ - givenName: ['Mary/Jane', "O'Connor"], - familyName: 'Smith-Jones', - nhsNumber: '1111222233', - birthDate: '1975-03-10', + expect(result).toBe( + '1of1_Lloyd_George_Record_[John Michael SMITH]_[1234567890]_[15-05-1990].pdf', + ); }); - const result = generateFileName(patientDetails); + it('generates correct filename with single given name', () => { + const patientDetails = buildPatientDetails({ + givenName: ['Jane'], + familyName: 'Doe', + nhsNumber: '0987654321', + birthDate: '1985-12-25', + }); - expect(result).toBe( - "1of1_Lloyd_George_Record_[Mary-Jane O'Connor SMITH-JONES]_[1111222233]_[10-03-1975].pdf", - ); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('handles multiple special characters that need to be replaced', () => { - const patientDetails = buildPatientDetails({ - givenName: ['Test'], - familyName: 'Sample*Family', - nhsNumber: '5555666677', - birthDate: '2000-01-01', + expect(result).toBe('1of1_Lloyd_George_Record_[Jane DOE]_[0987654321]_[25-12-1985].pdf'); }); - const result = generateFileName(patientDetails); + it('handles special characters in given name by replacing them with dashes', () => { + const patientDetails = buildPatientDetails({ + givenName: ['Mary/Jane', "O'Connor"], + familyName: 'Smith-Jones', + nhsNumber: '1111222233', + birthDate: '1975-03-10', + }); - expect(result).toBe( - '1of1_Lloyd_George_Record_[Test-Name- SAMPLE*FAMILY]_[5555666677]_[01-01-2000].pdf', - ); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('handles empty given name array', () => { - const patientDetails = buildPatientDetails({ - givenName: [], - familyName: 'OnlyFamily', - nhsNumber: '9999888877', - birthDate: '1965-07-20', + expect(result).toBe( + "1of1_Lloyd_George_Record_[Mary-Jane O'Connor SMITH-JONES]_[1111222233]_[10-03-1975].pdf", + ); }); - const result = generateFileName(patientDetails); + it('handles multiple special characters that need to be replaced', () => { + const patientDetails = buildPatientDetails({ + givenName: ['Test'], + familyName: 'Sample*Family', + nhsNumber: '5555666677', + birthDate: '2000-01-01', + }); - expect(result).toBe('1of1_Lloyd_George_Record_[ ONLYFAMILY]_[9999888877]_[20-07-1965].pdf'); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('handles birth date with single digit day and month', () => { - const patientDetails = buildPatientDetails({ - givenName: ['Alex'], - familyName: 'Wilson', - nhsNumber: '1122334455', - birthDate: '1992-02-05', + expect(result).toBe( + '1of1_Lloyd_George_Record_[Test-Name- SAMPLE*FAMILY]_[5555666677]_[01-01-2000].pdf', + ); }); - const result = generateFileName(patientDetails); + it('handles empty given name array', () => { + const patientDetails = buildPatientDetails({ + givenName: [], + familyName: 'OnlyFamily', + nhsNumber: '9999888877', + birthDate: '1965-07-20', + }); - expect(result).toBe('1of1_Lloyd_George_Record_[Alex WILSON]_[1122334455]_[05-02-1992].pdf'); - }); - - it('throws an error when patient details is null', () => { - expect(() => generateFileName(null)).toThrow( - 'Patient details are required to generate filename', - ); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('handles all special characters that should be replaced', () => { - const patientDetails = buildPatientDetails({ - givenName: ['Test,Name/With\\Various?Characters%With*More:|"And'], - familyName: 'NormalFamily', - nhsNumber: '1234567890', - birthDate: '1980-06-15', + expect(result).toBe('1of1_Lloyd_George_Record_[ ONLYFAMILY]_[9999888877]_[20-07-1965].pdf'); }); - const result = generateFileName(patientDetails); + it('handles birth date with single digit day and month', () => { + const patientDetails = buildPatientDetails({ + givenName: ['Alex'], + familyName: 'Wilson', + nhsNumber: '1122334455', + birthDate: '1992-02-05', + }); - expect(result).toBe( - '1of1_Lloyd_George_Record_[Test-Name-With-Various-Characters-With-More---And-Finally- NORMALFAMILY]_[1234567890]_[15-06-1980].pdf', - ); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('handles very long names correctly', () => { - const patientDetails = buildPatientDetails({ - givenName: ['Supercalifragilisticexpialidocious', 'AnExtremelyLongMiddleName'], - familyName: 'AnExtremelyLongFamilyNameThatGoesOnAndOn', - nhsNumber: '1111111111', - birthDate: '1995-09-30', + expect(result).toBe('1of1_Lloyd_George_Record_[Alex WILSON]_[1122334455]_[05-02-1992].pdf'); }); - const result = generateFileName(patientDetails); - - expect(result).toBe( - '1of1_Lloyd_George_Record_[Supercalifragilisticexpialidocious AnExtremelyLongMiddleName ANEXTREMELYLONGFAMILYNAMETHATGOESONANDON]_[1111111111]_[30-09-1995].pdf', - ); - }); - - it('handles invalid birth date gracefully', () => { - const patientDetails = buildPatientDetails({ - givenName: ['Test'], - familyName: 'User', - nhsNumber: '1234567890', - birthDate: 'invalid-date', + it('throws an error when patient details is null', () => { + expect(() => generateStitchedFileName(null, {} as DOCUMENT_TYPE_CONFIG)).toThrow( + 'Patient details are required to generate filename', + ); }); - const result = generateFileName(patientDetails); + it('handles all special characters that should be replaced', () => { + const patientDetails = buildPatientDetails({ + givenName: ['Test,Name/With\\Various?Characters%With*More:|"And'], + familyName: 'NormalFamily', + nhsNumber: '1234567890', + birthDate: '1980-06-15', + }); - expect(result).toBe('1of1_Lloyd_George_Record_[Test USER]_[1234567890]_[NaN-NaN-NaN].pdf'); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('handles whitespace in names correctly', () => { - const patientDetails = buildPatientDetails({ - givenName: [' John ', ' Michael '], - familyName: ' Smith ', - nhsNumber: '1234567890', - birthDate: '1990-01-01', + expect(result).toBe( + '1of1_Lloyd_George_Record_[Test-Name-With-Various-Characters-With-More---And-Finally- NORMALFAMILY]_[1234567890]_[15-06-1980].pdf', + ); }); - const result = generateFileName(patientDetails); - - expect(result).toBe( - '1of1_Lloyd_George_Record_[ John Michael SMITH ]_[1234567890]_[01-01-1990].pdf', - ); - }); -}); + it('handles very long names correctly', () => { + const patientDetails = buildPatientDetails({ + givenName: ['Supercalifragilisticexpialidocious', 'AnExtremelyLongMiddleName'], + familyName: 'AnExtremelyLongFamilyNameThatGoesOnAndOn', + nhsNumber: '1111111111', + birthDate: '1995-09-30', + }); -describe('getDocumentStatus', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - it('should request document status for all documents provided', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - buildDocument( - buildLgFile(2), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const data: DocumentStatusResult = {}; - documents.forEach((doc) => { - doc.ref = uuidv4(); - data[doc.ref] = { - status: DOCUMENT_STATUS.FINAL, - }; + expect(result).toBe( + '1of1_Lloyd_George_Record_[Supercalifragilisticexpialidocious AnExtremelyLongMiddleName ANEXTREMELYLONGFAMILYNAMETHATGOESONANDON]_[1111111111]_[30-09-1995].pdf', + ); }); - mockedAxios.get.mockResolvedValue({ - statusCode: 200, - data, - }); + it('handles invalid birth date gracefully', () => { + const patientDetails = buildPatientDetails({ + givenName: ['Test'], + familyName: 'User', + nhsNumber: '1234567890', + birthDate: 'invalid-date', + }); - const result = await getDocumentStatus({ - documents, - baseUrl, - baseHeaders, - nhsNumber, - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - expect(mockedAxios.get).toHaveBeenCalledWith(baseUrl + endpoints.DOCUMENT_STATUS, { - headers: baseHeaders, - params: { - patientId: nhsNumber, - docIds: documents.map((d) => d.ref).join(','), - }, + expect(result).toBe('1of1_Lloyd_George_Record_[Test USER]_[1234567890]_[NaN-NaN-NaN].pdf'); }); - expect(result).toBe(data); - }); - it('should request document status for a single document', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - documents[0].ref = uuidv4(); - - const data: DocumentStatusResult = { - [documents[0].ref]: { - status: DOCUMENT_STATUS.FINAL, - }, - }; + it('handles whitespace in names correctly', () => { + const patientDetails = buildPatientDetails({ + givenName: [' John ', ' Michael '], + familyName: ' Smith ', + nhsNumber: '1234567890', + birthDate: '1990-01-01', + }); - mockedAxios.get.mockResolvedValue({ - statusCode: 200, - data, - }); + const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); - const result = await getDocumentStatus({ - documents, - baseUrl, - baseHeaders, - nhsNumber, + expect(result).toBe( + '1of1_Lloyd_George_Record_[ John Michael SMITH ]_[1234567890]_[01-01-1990].pdf', + ); }); - - expect(mockedAxios.get).toHaveBeenCalledTimes(1); - expect(result).toEqual(data); }); - it('should throw error when document status request fails', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - documents[0].ref = uuidv4(); - - const mockError = { - response: { - status: 500, - data: { message: 'Internal server error' }, - }, - }; - - mockedAxios.get.mockRejectedValue(mockError); + describe('getDocumentStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - await expect( - getDocumentStatus({ + it('should request document status for all documents provided', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + buildDocument( + buildLgFile(2), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + const data: DocumentStatusResult = {}; + documents.forEach((doc) => { + doc.ref = uuidv4(); + data[doc.ref] = { + status: DOCUMENT_STATUS.FINAL, + }; + }); + + mockedAxios.get.mockResolvedValue({ + statusCode: 200, + data, + }); + + const result = await getDocumentStatus({ documents, baseUrl, baseHeaders, nhsNumber, - }), - ).rejects.toEqual(mockError); - }); + }); - it('should handle 404 error when document status not found', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - documents[0].ref = uuidv4(); - - const mockError = { - response: { - status: 404, - data: { message: 'Document not found' }, - }, - }; - - mockedAxios.get.mockRejectedValue(mockError); + expect(mockedAxios.get).toHaveBeenCalledWith(baseUrl + endpoints.DOCUMENT_STATUS, { + headers: baseHeaders, + params: { + patientId: nhsNumber, + docIds: documents.map((d) => d.ref).join(','), + }, + }); + expect(result).toBe(data); + }); - await expect( - getDocumentStatus({ - documents, - baseUrl, - baseHeaders, - nhsNumber, - }), - ).rejects.toEqual(mockError); - }); + it('should request document status for a single document', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - it('should handle network error when fetching document status', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; + documents[0].ref = uuidv4(); - documents[0].ref = uuidv4(); + const data: DocumentStatusResult = { + [documents[0].ref]: { + status: DOCUMENT_STATUS.FINAL, + }, + }; - const networkError = new Error('Network error'); - mockedAxios.get.mockRejectedValue(networkError); + mockedAxios.get.mockResolvedValue({ + statusCode: 200, + data, + }); - await expect( - getDocumentStatus({ + const result = await getDocumentStatus({ documents, baseUrl, baseHeaders, nhsNumber, - }), - ).rejects.toThrow('Network error'); - }); - - it('should correctly format document IDs as comma-separated string', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - buildDocument( - buildLgFile(2), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - buildDocument( - buildLgFile(3), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - const docId1 = uuidv4(); - const docId2 = uuidv4(); - const docId3 = uuidv4(); - - documents[0].ref = docId1; - documents[1].ref = docId2; - documents[2].ref = docId3; - - const data: DocumentStatusResult = { - [docId1]: { status: DOCUMENT_STATUS.FINAL }, - [docId2]: { status: DOCUMENT_STATUS.FINAL }, - [docId3]: { status: DOCUMENT_STATUS.FINAL }, - }; + }); - mockedAxios.get.mockResolvedValue({ - statusCode: 200, - data, + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(result).toEqual(data); }); - await getDocumentStatus({ - documents, - baseUrl, - baseHeaders, - nhsNumber, + it('should throw error when document status request fails', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + documents[0].ref = uuidv4(); + + const mockError = { + response: { + status: 500, + data: { message: 'Internal server error' }, + }, + }; + + mockedAxios.get.mockRejectedValue(mockError); + + await expect( + getDocumentStatus({ + documents, + baseUrl, + baseHeaders, + nhsNumber, + }), + ).rejects.toEqual(mockError); }); - expect(mockedAxios.get).toHaveBeenCalledWith( - baseUrl + endpoints.DOCUMENT_STATUS, - expect.objectContaining({ - params: { - patientId: nhsNumber, - docIds: `${docId1},${docId2},${docId3}`, + it('should handle 404 error when document status not found', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + documents[0].ref = uuidv4(); + + const mockError = { + response: { + status: 404, + data: { message: 'Document not found' }, }, - }), - ); - }); - - it('should pass correct headers to axios get request', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - documents[0].ref = uuidv4(); - - const customHeaders = { - 'Content-Type': 'application/json', - Authorization: 'Bearer token123', - 'X-Custom-Header': 'custom-value', - }; + }; - const data: DocumentStatusResult = { - [documents[0].ref]: { status: DOCUMENT_STATUS.FINAL }, - }; + mockedAxios.get.mockRejectedValue(mockError); - mockedAxios.get.mockResolvedValue({ - statusCode: 200, - data, + await expect( + getDocumentStatus({ + documents, + baseUrl, + baseHeaders, + nhsNumber, + }), + ).rejects.toEqual(mockError); }); - await getDocumentStatus({ - documents, - baseUrl, - baseHeaders: customHeaders, - nhsNumber, + it('should handle network error when fetching document status', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + documents[0].ref = uuidv4(); + + const networkError = new Error('Network error'); + mockedAxios.get.mockRejectedValue(networkError); + + await expect( + getDocumentStatus({ + documents, + baseUrl, + baseHeaders, + nhsNumber, + }), + ).rejects.toThrow('Network error'); }); - expect(mockedAxios.get).toHaveBeenCalledWith( - baseUrl + endpoints.DOCUMENT_STATUS, - expect.objectContaining({ - headers: customHeaders, - }), - ); - }); + it('should correctly format document IDs as comma-separated string', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + buildDocument( + buildLgFile(2), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + buildDocument( + buildLgFile(3), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + const docId1 = uuidv4(); + const docId2 = uuidv4(); + const docId3 = uuidv4(); + + documents[0].ref = docId1; + documents[1].ref = docId2; + documents[2].ref = docId3; + + const data: DocumentStatusResult = { + [docId1]: { status: DOCUMENT_STATUS.FINAL }, + [docId2]: { status: DOCUMENT_STATUS.FINAL }, + [docId3]: { status: DOCUMENT_STATUS.FINAL }, + }; + + mockedAxios.get.mockResolvedValue({ + statusCode: 200, + data, + }); + + await getDocumentStatus({ + documents, + baseUrl, + baseHeaders, + nhsNumber, + }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + baseUrl + endpoints.DOCUMENT_STATUS, + expect.objectContaining({ + params: { + patientId: nhsNumber, + docIds: `${docId1},${docId2},${docId3}`, + }, + }), + ); + }); - it('should handle empty status result', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; + it('should pass correct headers to axios get request', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + documents[0].ref = uuidv4(); + + const customHeaders = { + 'Content-Type': 'application/json', + Authorization: 'Bearer token123', + 'X-Custom-Header': 'custom-value', + }; - documents[0].ref = uuidv4(); + const data: DocumentStatusResult = { + [documents[0].ref]: { status: DOCUMENT_STATUS.FINAL }, + }; - const data: DocumentStatusResult = {}; + mockedAxios.get.mockResolvedValue({ + statusCode: 200, + data, + }); - mockedAxios.get.mockResolvedValue({ - statusCode: 200, - data, + await getDocumentStatus({ + documents, + baseUrl, + baseHeaders: customHeaders, + nhsNumber, + }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + baseUrl + endpoints.DOCUMENT_STATUS, + expect.objectContaining({ + headers: customHeaders, + }), + ); }); - const result = await getDocumentStatus({ - documents, - baseUrl, - baseHeaders, - nhsNumber, - }); + it('should handle empty status result', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - expect(result).toEqual({}); - }); + documents[0].ref = uuidv4(); - it('should handle 503 service unavailable error', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; - - documents[0].ref = uuidv4(); - - const mockError = { - response: { - status: 503, - data: { message: 'Service temporarily unavailable' }, - }, - }; + const data: DocumentStatusResult = {}; - mockedAxios.get.mockRejectedValue(mockError); + mockedAxios.get.mockResolvedValue({ + statusCode: 200, + data, + }); - await expect( - getDocumentStatus({ + const result = await getDocumentStatus({ documents, baseUrl, baseHeaders, nhsNumber, - }), - ).rejects.toEqual(mockError); - }); + }); - it('should handle malformed response gracefully', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; + expect(result).toEqual({}); + }); - documents[0].ref = uuidv4(); + it('should handle 503 service unavailable error', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + documents[0].ref = uuidv4(); + + const mockError = { + response: { + status: 503, + data: { message: 'Service temporarily unavailable' }, + }, + }; - const malformedData = null; + mockedAxios.get.mockRejectedValue(mockError); - mockedAxios.get.mockResolvedValue({ - statusCode: 200, - data: malformedData, + await expect( + getDocumentStatus({ + documents, + baseUrl, + baseHeaders, + nhsNumber, + }), + ).rejects.toEqual(mockError); }); - const result = await getDocumentStatus({ - documents, - baseUrl, - baseHeaders, - nhsNumber, + it('should handle malformed response gracefully', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; + + documents[0].ref = uuidv4(); + + const malformedData = null; + + mockedAxios.get.mockResolvedValue({ + statusCode: 200, + data: malformedData, + }); + + const result = await getDocumentStatus({ + documents, + baseUrl, + baseHeaders, + nhsNumber, + }); + + expect(result).toBeNull(); }); - expect(result).toBeNull(); - }); + it('should construct correct document status URL', async () => { + const documents = [ + buildDocument( + buildLgFile(1), + DOCUMENT_UPLOAD_STATE.UPLOADING, + DOCUMENT_TYPE.LLOYD_GEORGE, + ), + ]; - it('should construct correct document status URL', async () => { - const documents = [ - buildDocument( - buildLgFile(1), - DOCUMENT_UPLOAD_STATE.UPLOADING, - DOCUMENT_TYPE.LLOYD_GEORGE, - ), - ]; + documents[0].ref = uuidv4(); - documents[0].ref = uuidv4(); + const customBaseUrl = 'https://api.example.com/v1'; - const customBaseUrl = 'https://api.example.com/v1'; + const data: DocumentStatusResult = { + [documents[0].ref]: { status: DOCUMENT_STATUS.FINAL }, + }; - const data: DocumentStatusResult = { - [documents[0].ref]: { status: DOCUMENT_STATUS.FINAL }, - }; + mockedAxios.get.mockResolvedValue({ + statusCode: 200, + data, + }); - mockedAxios.get.mockResolvedValue({ - statusCode: 200, - data, - }); + await getDocumentStatus({ + documents, + baseUrl: customBaseUrl, + baseHeaders, + nhsNumber, + }); - await getDocumentStatus({ - documents, - baseUrl: customBaseUrl, - baseHeaders, - nhsNumber, + expect(mockedAxios.get).toHaveBeenCalledWith( + customBaseUrl + endpoints.DOCUMENT_STATUS, + expect.any(Object), + ); }); - - expect(mockedAxios.get).toHaveBeenCalledWith( - customBaseUrl + endpoints.DOCUMENT_STATUS, - expect.any(Object), - ); }); }); diff --git a/app/src/helpers/requests/uploadDocuments.ts b/app/src/helpers/requests/uploadDocuments.ts index be3fd71b2..2d90e811d 100644 --- a/app/src/helpers/requests/uploadDocuments.ts +++ b/app/src/helpers/requests/uploadDocuments.ts @@ -6,13 +6,13 @@ import axios, { AxiosError } from 'axios'; import { DocumentStatusResult, S3Upload, - S3UploadFields, UploadSession, } from '../../types/generic/uploadResult'; import { Dispatch, SetStateAction } from 'react'; import { extractUploadSession, setSingleDocument } from '../utils/uploadDocumentHelpers'; import { PatientDetails } from '../../types/generic/patientDetails'; import { formatDateWithDashes } from '../utils/formatDate'; +import { DOCUMENT_TYPE_CONFIG } from '../utils/documentType'; type UploadDocumentsArgs = { documents: UploadDocument[]; @@ -34,16 +34,13 @@ export const uploadDocumentToS3 = async ({ document, }: UploadDocumentsToS3Args): Promise => { const documentMetadata: S3Upload = uploadSession[document.id]; - const formData = new FormData(); - const docFields: S3UploadFields = documentMetadata.fields ?? []; - Object.entries(docFields).forEach(([key, value]) => { - formData.append(key, value); - }); - formData.append('file', document.file); const s3url = documentMetadata.url; const axiosMethod = Object.keys(documentMetadata).includes('fields') ? axios.post : axios.put; try { - return await axiosMethod(s3url, formData, { + return await axiosMethod(s3url, document.file, { + headers: { + 'Content-Type': document.file.type, + }, onUploadProgress: (progress): void => { const { loaded, total } = progress; if (total) { @@ -64,14 +61,17 @@ export const uploadDocumentToS3 = async ({ } }; -export const generateFileName = (patientDetails: PatientDetails | null): string => { +export const generateStitchedFileName = ( + patientDetails: PatientDetails | null, + documentConfig: DOCUMENT_TYPE_CONFIG, +): string => { if (!patientDetails) { throw new Error('Patient details are required to generate filename'); } // replace commas and other characters unfriendly characters to file paths const givenName = patientDetails.givenName.join(' ').replace(/[,/\\?%*:|"<>]/g, '-'); - const filename = `1of1_Lloyd_George_Record_[${givenName} ${patientDetails.familyName.toUpperCase()}]_[${patientDetails.nhsNumber}]_[${formatDateWithDashes(new Date(patientDetails.birthDate))}].pdf`; + const filename = `${documentConfig.stitchedFilenamePrefix}_[${givenName} ${patientDetails.familyName.toUpperCase()}]_[${patientDetails.nhsNumber}]_[${formatDateWithDashes(new Date(patientDetails.birthDate))}].pdf`; return filename; }; diff --git a/app/src/helpers/test/testBuilders.ts b/app/src/helpers/test/testBuilders.ts index ba88d56f1..bc0f288f7 100644 --- a/app/src/helpers/test/testBuilders.ts +++ b/app/src/helpers/test/testBuilders.ts @@ -18,7 +18,7 @@ import { DeceasedAccessAuditReasons, PatientAccessAudit, } from '../../types/generic/accessAudit'; -import { DOCUMENT_TYPE } from '../utils/documentType'; +import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../utils/documentType'; const buildUserAuth = (userAuthOverride?: Partial): UserAuth => { const auth: UserAuth = { @@ -174,6 +174,62 @@ const buildPatientAccessAudit = (): PatientAccessAudit[] => { ]; }; +const buildDocumentConfig = ( + configOverride?: Partial, +): DOCUMENT_TYPE_CONFIG => { + return { + snomedCode: '16521000000101', + displayName: 'Scanned Paper Notes', + canBeUpdated: true, + associatedSnomed: '', + multifileUpload: true, + multifileZipped: false, + multifileReview: true, + canBeDiscarded: true, + stitched: true, + singleDocumentOnly: true, + stitchedFilenamePrefix: '1of1_Lloyd_George_Record', + acceptedFileTypes: ['PDF'], + content: { + viewDocumentTitle: 'Scanned paper notes', + addFilesSelectTitle: 'Add scanned paper notes files to this record', + uploadFilesSelectTitle: 'Choose scanned paper notes files to upload', + uploadFilesBulletPoints: [ + 'You can only upload PDF files', + 'Check your files open correctly', + 'Remove any passwords from files', + "If there is a problem with your files during upload, you'll need to resolve these before continuing", + ], + chooseFilesMessage: 'Choose PDF files to upload', + chooseFilesButtonLabel: 'Choose PDF files', + chooseFilesWarningText: '', + confirmFilesTitle: 'Check your files before uploading', + beforeYouUploadTitle: 'Before you upload', + previewUploadTitle: 'Preview this scanned paper notes record', + addFilesSuccessMessage: + 'You have successfully added additional files to the Scanned paper notes record for:', + uploadFilesSuccessMessage: + 'You have successfully uploaded a Scanned paper notes record for:', + uploadFilesExtraParagraph: + "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 16521000000101.", + uploadReviewSuccessMessage: + 'You have successfully uploaded a Scanned paper notes record for:', + }, + ...configOverride, + }; +}; + +const buildMockUploadSession = (documents: UploadDocument[]): UploadSession => { + const session: UploadSession = {}; + documents.forEach((doc) => { + session[doc.id] = { + url: 'http://localhost/mock-s3-upload-url', + } as any; + }); + + return session; +}; + export { buildPatientDetails, buildTextFile, @@ -185,4 +241,6 @@ export { buildConfig, buildUploadSession, buildPatientAccessAudit, + buildDocumentConfig, + buildMockUploadSession, }; diff --git a/app/src/helpers/utils/documentType.test.ts b/app/src/helpers/utils/documentType.test.ts index ac0169957..bf64c216d 100644 --- a/app/src/helpers/utils/documentType.test.ts +++ b/app/src/helpers/utils/documentType.test.ts @@ -1,55 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { DOCUMENT_TYPE, getDocumentTypeLabel, getConfigForDocType } from './documentType'; -// Mock the JSON config files -vi.mock('../../config/lloydGeorgeConfig.json', () => ({ - default: { - snomedCode: '16521000000101', - displayName: 'Scanned paper notes', - canBeUpdated: true, - associatedSnomed: 'test-snomed', - multifileUpload: true, - multifileZipped: false, - multifileReview: true, - canBeDiscarded: false, - stitched: true, - acceptedFileTypes: ['pdf'], - content: { test: 'test' }, - }, -})); - -vi.mock('../../config/electronicHealthRecordConfig.json', () => ({ - default: { - snomedCode: '16521000000102', - displayName: 'Electronic health record', - canBeUpdated: false, - associatedSnomed: 'ehr-snomed', - multifileUpload: false, - multifileZipped: true, - multifileReview: false, - canBeDiscarded: true, - stitched: false, - acceptedFileTypes: ['xml', 'json'], - content: { ehr: 'data' }, - }, -})); - -vi.mock('../../config/electronicHealthRecordAttachmentsConfig.json', () => ({ - default: { - snomedCode: '16521000000103', - displayName: 'Electronic health record attachments', - canBeUpdated: true, - associatedSnomed: 'attachment-snomed', - multifileUpload: true, - multifileZipped: true, - multifileReview: true, - canBeDiscarded: true, - stitched: false, - acceptedFileTypes: ['pdf', 'jpg', 'png'], - content: { attachment: 'file' }, - }, -})); - describe('documentType', () => { describe('getDocumentTypeLabel', () => { it('should return correct label for LLOYD_GEORGE', () => { @@ -80,28 +31,28 @@ describe('documentType', () => { describe('getConfigForDocType', () => { it('should return config for LLOYD_GEORGE', () => { const config = getConfigForDocType(DOCUMENT_TYPE.LLOYD_GEORGE); - expect(config.snomedCode).toBe('16521000000101'); - expect(config.displayName).toBe('Scanned paper notes'); + expect(config.snomedCode).toBe(DOCUMENT_TYPE.LLOYD_GEORGE); + expect(config.displayName).toBe('scanned paper notes'); expect(config.canBeUpdated).toBe(true); }); it('should return config for EHR', () => { const config = getConfigForDocType(DOCUMENT_TYPE.EHR); - expect(config.snomedCode).toBe('16521000000102'); - expect(config.displayName).toBe('Electronic health record'); + expect(config.snomedCode).toBe(DOCUMENT_TYPE.EHR); + expect(config.displayName).toBe('electronic health record'); expect(config.canBeUpdated).toBe(false); }); it('should return config for EHR_ATTACHMENTS', () => { const config = getConfigForDocType(DOCUMENT_TYPE.EHR_ATTACHMENTS); - expect(config.snomedCode).toBe('16521000000103'); - expect(config.displayName).toBe('Electronic health record attachments'); + expect(config.snomedCode).toBe(DOCUMENT_TYPE.EHR_ATTACHMENTS); + expect(config.displayName).toBe('electronic health record attachments'); expect(config.multifileUpload).toBe(true); }); it('should throw error for unsupported document type', () => { expect(() => getConfigForDocType(DOCUMENT_TYPE.LETTERS_AND_DOCS)).toThrow( - 'No config found for document type: 16521000000104', + `No config found for document type: ${DOCUMENT_TYPE.LETTERS_AND_DOCS}`, ); }); diff --git a/app/src/helpers/utils/documentType.ts b/app/src/helpers/utils/documentType.ts index f3bce73eb..157addc2c 100644 --- a/app/src/helpers/utils/documentType.ts +++ b/app/src/helpers/utils/documentType.ts @@ -4,24 +4,28 @@ import electronicHealthRecordAttachmentsConfig from '../../config/electronicHeal export enum DOCUMENT_TYPE { LLOYD_GEORGE = '16521000000101', - EHR = '16521000000102', // TBC - EHR_ATTACHMENTS = '16521000000103', // TBC - LETTERS_AND_DOCS = '16521000000104', // TBC - ALL = '16521000000101,16521000000102,16521000000103,16521000000104', // TBC + EHR = '717301000000104', // TBC + EHR_ATTACHMENTS = '24511000000107', // TBC + LETTERS_AND_DOCS = '162931000000103', // TBC + ALL = '16521000000101,717301000000104,24511000000107,162931000000103', // TBC } export type DOCUMENT_TYPE_CONFIG = { snomedCode: string; displayName: string; + filenameOverride?: string; canBeUpdated: boolean; associatedSnomed: string; multifileUpload: boolean; multifileZipped: boolean; + zippedFilename?: string; multifileReview: boolean; canBeDiscarded: boolean; stitched: boolean; + singleDocumentOnly: boolean; + stitchedFilenamePrefix?: string; acceptedFileTypes: string[]; - content: { [key: string]: string }; + content: { [key: string]: string | string[] }; }; export const getDocumentTypeLabel = (docType: DOCUMENT_TYPE): string => { diff --git a/app/src/helpers/utils/documentUpload.test.ts b/app/src/helpers/utils/documentUpload.test.ts new file mode 100644 index 000000000..4518c8dc3 --- /dev/null +++ b/app/src/helpers/utils/documentUpload.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { reduceDocumentsForUpload } from './documentUpload'; +import { PatientDetails } from '../../types/generic/patientDetails'; +import { DOCUMENT_UPLOAD_STATE, UploadDocument } from '../../types/pages/UploadDocumentsPage/types'; +import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from './documentType'; +import { generateStitchedFileName } from '../requests/uploadDocuments'; +import { zipFiles } from './zip'; + +vi.mock('../requests/uploadDocuments'); +vi.mock('./zip'); +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'mock-uuid-123'), +})); + +describe('documentUpload', () => { + const mockPatientDetails = { + nhsNumber: '1234567890', + givenName: ['John'], + familyName: 'Doe', + birthDate: '1980-01-01', + postalCode: 'AB12 3CD', + superseded: false, + restricted: false, + active: true, + deceased: false, + } as PatientDetails; + + const mockDocuments: UploadDocument[] = [ + { + id: 'doc1', + file: new File(['content1'], 'file1.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + attempts: 0, + versionId: 'v1', + }, + { + id: 'doc2', + file: new File(['content2'], 'file2.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + attempts: 0, + versionId: 'v1', + }, + ]; + + const mockMergedPdfBlob = new Blob(['merged pdf content'], { type: 'application/pdf' }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('reduceDocumentsForUpload', () => { + it('should return stitched document when documentConfig.stitched is true', async () => { + const documentConfig: DOCUMENT_TYPE_CONFIG = { + stitched: true, + multifileZipped: false, + snomedCode: DOCUMENT_TYPE.LLOYD_GEORGE, + displayName: 'Scanned paper notes', + canBeUpdated: false, + associatedSnomed: '', + multifileUpload: false, + multifileReview: false, + canBeDiscarded: false, + singleDocumentOnly: true, + acceptedFileTypes: [], + content: {}, + }; + + vi.mocked(generateStitchedFileName).mockReturnValue('stitched_file.pdf'); + + const result = await reduceDocumentsForUpload( + mockDocuments, + documentConfig, + mockMergedPdfBlob, + mockPatientDetails, + 'version123', + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'mock-uuid-123', + file: expect.any(File), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + attempts: 0, + versionId: 'version123', + }); + expect(result[0].file.name).toBe('stitched_file.pdf'); + expect(generateStitchedFileName).toHaveBeenCalledWith( + mockPatientDetails, + documentConfig, + ); + }); + + it('should return zipped document when documentConfig.multifileZipped is true', async () => { + const documentConfig: DOCUMENT_TYPE_CONFIG = { + stitched: false, + multifileZipped: true, + snomedCode: DOCUMENT_TYPE.LLOYD_GEORGE, + zippedFilename: 'test_documents', + displayName: 'Scanned paper notes', + canBeUpdated: false, + associatedSnomed: '', + multifileUpload: false, + multifileReview: false, + canBeDiscarded: false, + singleDocumentOnly: true, + acceptedFileTypes: [], + content: {}, + }; + + const mockZippedBlob = new Blob(['zipped content'], { type: 'application/zip' }); + vi.mocked(zipFiles).mockResolvedValue(mockZippedBlob); + + const result = await reduceDocumentsForUpload( + mockDocuments, + documentConfig, + mockMergedPdfBlob, + mockPatientDetails, + 'version123', + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'mock-uuid-123', + file: expect.any(File), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + attempts: 0, + versionId: 'version123', + }); + expect(result[0].file.name).toBe('test_documents_(2).zip'); + expect(result[0].file.type).toBe('application/zip'); + expect(zipFiles).toHaveBeenCalledWith(mockDocuments); + }); + + it('should return original documents when neither stitched nor multifileZipped is true', async () => { + const documentConfig: DOCUMENT_TYPE_CONFIG = { + stitched: false, + multifileZipped: false, + snomedCode: DOCUMENT_TYPE.LLOYD_GEORGE, + zippedFilename: 'test_documents', + displayName: 'Scanned paper notes', + canBeUpdated: false, + associatedSnomed: '', + multifileUpload: false, + multifileReview: false, + canBeDiscarded: false, + singleDocumentOnly: true, + acceptedFileTypes: [], + content: {}, + }; + + const result = await reduceDocumentsForUpload( + mockDocuments, + documentConfig, + mockMergedPdfBlob, + mockPatientDetails, + 'version123', + ); + + expect(result).toEqual(mockDocuments); + expect(generateStitchedFileName).not.toHaveBeenCalled(); + expect(zipFiles).not.toHaveBeenCalled(); + }); + + it('should handle empty documents array for zipped files', async () => { + const documentConfig: DOCUMENT_TYPE_CONFIG = { + stitched: false, + multifileZipped: true, + snomedCode: DOCUMENT_TYPE.LLOYD_GEORGE, + zippedFilename: 'empty_documents', + displayName: 'Scanned paper notes', + canBeUpdated: false, + associatedSnomed: '', + multifileUpload: false, + multifileReview: false, + canBeDiscarded: false, + singleDocumentOnly: true, + acceptedFileTypes: [], + content: {}, + }; + + const mockZippedBlob = new Blob([''], { type: 'application/zip' }); + vi.mocked(zipFiles).mockResolvedValue(mockZippedBlob); + + const result = await reduceDocumentsForUpload( + [], + documentConfig, + mockMergedPdfBlob, + mockPatientDetails, + 'version123', + ); + + expect(result).toHaveLength(1); + expect(result[0].file.name).toBe('empty_documents_(0).zip'); + }); + }); +}); diff --git a/app/src/helpers/utils/documentUpload.ts b/app/src/helpers/utils/documentUpload.ts new file mode 100644 index 000000000..3ac49132a --- /dev/null +++ b/app/src/helpers/utils/documentUpload.ts @@ -0,0 +1,51 @@ +import { PatientDetails } from '../../types/generic/patientDetails'; +import { DOCUMENT_UPLOAD_STATE, UploadDocument } from '../../types/pages/UploadDocumentsPage/types'; +import { generateStitchedFileName } from '../requests/uploadDocuments'; +import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from './documentType'; +import { v4 as uuidv4 } from 'uuid'; +import { zipFiles } from './zip'; + +export const reduceDocumentsForUpload = async ( + documents: UploadDocument[], + documentConfig: DOCUMENT_TYPE_CONFIG, + mergedPdfBlob: Blob, + patientDetails: PatientDetails, + versionId: string, +): Promise => { + if (documentConfig.stitched) { + const filename = generateStitchedFileName(patientDetails, documentConfig); + documents = [ + { + id: uuidv4(), + file: new File([mergedPdfBlob], filename, { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: documentConfig.snomedCode as DOCUMENT_TYPE, + attempts: 0, + versionId: versionId, + }, + ]; + } + + if (documentConfig.multifileZipped) { + const filename = `${documentConfig.zippedFilename}_(${documents.length}).zip`; + + const zip = await zipFiles(documents); + + documents = [ + { + id: uuidv4(), + file: new File([zip], filename, { + type: 'application/zip', + }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + progress: 0, + docType: documentConfig.snomedCode as DOCUMENT_TYPE, + attempts: 0, + versionId, + }, + ]; + } + + return documents; +}; diff --git a/app/src/helpers/utils/fileUploadErrorMessages.ts b/app/src/helpers/utils/fileUploadErrorMessages.ts index ca83f6967..c9c4ebfac 100644 --- a/app/src/helpers/utils/fileUploadErrorMessages.ts +++ b/app/src/helpers/utils/fileUploadErrorMessages.ts @@ -5,6 +5,7 @@ type UploadFilesError = ErrorMessageListItem; export enum UPLOAD_FILE_ERROR_TYPE { noFiles = 'noFiles', + tooManyFiles = 'tooManyFiles', passwordProtected = 'passwordProtected', invalidPdf = 'invalidPdf', emptyPdf = 'emptyPdf', @@ -40,6 +41,10 @@ export const fileUploadErrorMessages: ErrorMessageType = { inline: 'Select a file to upload', errorBox: 'Select a file to upload', }, + tooManyFiles: { + inline: 'You have selected too many files to upload', + errorBox: 'You have selected too many files to upload', + }, invalidPdf: { inline: 'The selected file is be damaged or unreadable. Fix it to continue with upload.', errorBox: 'The selected file is be damaged or unreadable. Fix it to continue with upload.', diff --git a/app/src/helpers/utils/uploadDocumentHelpers.ts b/app/src/helpers/utils/uploadDocumentHelpers.ts index 1c4efad9e..a6bdd6332 100644 --- a/app/src/helpers/utils/uploadDocumentHelpers.ts +++ b/app/src/helpers/utils/uploadDocumentHelpers.ts @@ -70,9 +70,9 @@ export const getUploadMessage = ({ state, progress }: UploadDocument): string => export const allDocsHaveState = ( documents: UploadDocument[], - state: DOCUMENT_UPLOAD_STATE, + states: DOCUMENT_UPLOAD_STATE[], ): boolean => { - return !!documents?.length && documents.every((doc) => doc.state === state); + return !!documents?.length && documents.every((doc) => states.includes(doc.state)); }; export const extractUploadSession = (data: any): UploadSession => { diff --git a/app/src/helpers/utils/zip.test.ts b/app/src/helpers/utils/zip.test.ts new file mode 100644 index 000000000..7c7718021 --- /dev/null +++ b/app/src/helpers/utils/zip.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { zipFiles } from './zip'; +import { UploadDocument } from '../../types/pages/UploadDocumentsPage/types'; + +const mockedAdd = vi.fn(); +const mockedClose = vi.fn(); + +vi.mock('@zip.js/zip.js', () => ({ + BlobReader: vi.fn(class {}), + BlobWriter: vi.fn(class {}), + ZipWriter: vi.fn( + class { + add = mockedAdd; + close = mockedClose; + }, + ), +})); + +describe('zipFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should create a zip file from documents', async () => { + const mockBlob = new Blob(['zip content']); + mockedClose.mockResolvedValue(mockBlob); + + const documents: UploadDocument[] = [ + { file: new File(['content1'], 'file1.txt') } as UploadDocument, + { file: new File(['content2'], 'file2.txt') } as UploadDocument, + ]; + + const result = await zipFiles(documents); + + expect(mockedAdd).toHaveBeenCalledTimes(2); + expect(mockedAdd).toHaveBeenCalledWith('file1.txt', {}, { useWebWorkers: false }); + expect(mockedAdd).toHaveBeenCalledWith('file2.txt', {}, { useWebWorkers: false }); + expect(mockedClose).toHaveBeenCalled(); + expect(result).toBe(mockBlob); + }); + + it('should handle empty documents array', async () => { + const mockBlob = new Blob(['empty zip']); + mockedClose.mockResolvedValue(mockBlob); + + const result = await zipFiles([]); + + expect(mockedAdd).not.toHaveBeenCalled(); + expect(mockedClose).toHaveBeenCalled(); + expect(result).toBe(mockBlob); + }); + + it('should handle single document', async () => { + const mockBlob = new Blob(['single file zip']); + mockedClose.mockResolvedValue(mockBlob); + + const documents: UploadDocument[] = [ + { file: new File(['content'], 'single.pdf') } as UploadDocument, + ]; + + const result = await zipFiles(documents); + + expect(mockedAdd).toHaveBeenCalledOnce(); + expect(mockedAdd).toHaveBeenCalledWith('single.pdf', {}, { useWebWorkers: false }); + expect(result).toBe(mockBlob); + }); +}); diff --git a/app/src/helpers/utils/zip.ts b/app/src/helpers/utils/zip.ts new file mode 100644 index 000000000..5ab31d11c --- /dev/null +++ b/app/src/helpers/utils/zip.ts @@ -0,0 +1,16 @@ +import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'; +import { UploadDocument } from '../../types/pages/UploadDocumentsPage/types'; + +export const zipFiles = async (documents: UploadDocument[]): Promise => { + const blobWriter = new BlobWriter(); + const zipWriter = new ZipWriter(blobWriter); + + for (const document of documents) { + const blobReader = new BlobReader(document.file); + await zipWriter.add(document.file.name, blobReader, { + useWebWorkers: false, + }); + } + + return await zipWriter.close(); +}; diff --git a/app/src/pages/adminPage/AdminPage.test.tsx b/app/src/pages/adminPage/AdminPage.test.tsx index 7043854a3..6813f4303 100644 --- a/app/src/pages/adminPage/AdminPage.test.tsx +++ b/app/src/pages/adminPage/AdminPage.test.tsx @@ -5,6 +5,9 @@ import { describe, expect, it, vi } from 'vitest'; import { routeChildren } from '../../types/generic/routes'; vi.mock('../../../helpers/hooks/useTitle'); +vi.mock('../../styles/right-chevron-circle.svg', () => ({ + ReactComponent: () => 'svg', +})); describe('AdminPage', (): void => { describe('Rendering', (): void => { diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx index 5e859634c..eb11337f0 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx @@ -56,7 +56,6 @@ describe('', () => { }); import.meta.env.VITE_ENVIRONMENT = 'vitest'; - mockedUsePatient.mockReturnValue(mockPatient); mockedUseConfig.mockReturnValue({ featureFlags: { uploadDocumentIteration3Enabled: true, @@ -142,12 +141,63 @@ describe('', () => { expect(screen.getByTestId('service-error')).toBeInTheDocument(); }); }); + + it.each([ + { + featureFlagEnabled: true, + role: REPOSITORY_ROLE.GP_ADMIN, + deceased: false, + uploadBtnVisible: true, + }, + { + featureFlagEnabled: true, + role: REPOSITORY_ROLE.GP_CLINICAL, + deceased: false, + uploadBtnVisible: true, + }, + { + featureFlagEnabled: true, + role: REPOSITORY_ROLE.PCSE, + deceased: false, + uploadBtnVisible: false, + }, + { + featureFlagEnabled: true, + role: REPOSITORY_ROLE.GP_ADMIN, + deceased: true, + uploadBtnVisible: false, + }, + { + featureFlagEnabled: false, + role: REPOSITORY_ROLE.GP_ADMIN, + deceased: false, + uploadBtnVisible: false, + }, + ])( + 'displays upload button when %s', + async ({ featureFlagEnabled, role, deceased, uploadBtnVisible }) => { + mockedGetSearchResults.mockResolvedValue([buildSearchResult()]); + mockedUseConfig.mockReturnValue({ + featureFlags: { + uploadDocumentIteration3Enabled: featureFlagEnabled, + }, + }); + + renderPage(history, role, deceased); + + await waitFor(() => { + expect(screen.queryAllByTestId('upload-button')).toHaveLength( + uploadBtnVisible ? 1 : 0, + ); + }); + }, + ); }); describe('Accessibility', () => { it('pass accessibility checks at loading screen', async () => { - mockedGetSearchResults.mockImplementation(() => - new Promise((resolve) => setTimeout(resolve, 20000)), + mockedGetSearchResults.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 20000)), ); renderPage(history); @@ -228,7 +278,9 @@ describe('', () => { renderPage(history); await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith(expect.stringContaining(routes.SERVER_ERROR)); + expect(mockedUseNavigate).toHaveBeenCalledWith( + expect.stringContaining(routes.SERVER_ERROR), + ); }); }); @@ -313,7 +365,9 @@ describe('', () => { }); }); - const renderPage = (history: History, role?: REPOSITORY_ROLE): void => { + const renderPage = (history: History, role?: REPOSITORY_ROLE, deceased?: boolean): void => { + mockedUsePatient.mockReturnValue(buildPatientDetails({ deceased: deceased })); + const auth: Session = { auth: buildUserAuth({ role: role ?? REPOSITORY_ROLE.GP_ADMIN }), isLoggedIn: true, diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx index 79d013311..d6ec003a2 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx @@ -17,7 +17,7 @@ import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; import ErrorBox from '../../components/layout/errorBox/ErrorBox'; import { errorToParams } from '../../helpers/utils/errorToParams'; import useTitle from '../../helpers/hooks/useTitle'; -import { getLastURLPath } from '../../helpers/utils/urlManipulations'; +import { getLastURLPath, useEnhancedNavigate } from '../../helpers/utils/urlManipulations'; import PatientSummary, { PatientInfo, } from '../../components/generic/patientSummary/PatientSummary'; @@ -32,6 +32,7 @@ import { DOCUMENT_TYPE } from '../../helpers/utils/documentType'; import BackButton from '../../components/generic/backButton/BackButton'; import ProgressBar from '../../components/generic/progressBar/ProgressBar'; import DeleteSubmitStage from '../../components/blocks/_delete/deleteSubmitStage/DeleteSubmitStage'; +import { Button } from 'nhsuk-react-components'; const DocumentSearchResultsPage = (): React.JSX.Element => { const patientDetails = usePatient(); @@ -108,7 +109,7 @@ const DocumentSearchResultsPage = (): React.JSX.Element => { ...documentItem, }); navigate(routeChildren.DOCUMENT_VIEW); - + void loadDocument(documentItem.id); }; @@ -131,6 +132,11 @@ const DocumentSearchResultsPage = (): React.JSX.Element => { }); } else if (error.response?.status === 403) { navigate(routes.SESSION_EXPIRED); + } else if (error.response?.status === 404) { + await handleViewDocSuccess({ + url: '', + contentType: '', + }); } else { navigate(routes.SERVER_ERROR + errorToParams(error)); } @@ -139,7 +145,7 @@ const DocumentSearchResultsPage = (): React.JSX.Element => { const handleViewDocSuccess = async (documentResponse: GetDocumentResponse): Promise => { setDocumentReference({ - url: await getObjectUrl(documentResponse.url), + url: documentResponse.url ? await getObjectUrl(documentResponse.url) : null, isPdf: documentResponse.contentType === 'application/pdf', ...activeSearchResult.current, } as DocumentReference); @@ -203,6 +209,59 @@ const DocumentSearchResultsPage = (): React.JSX.Element => { ); }; +type SearchResultsProps = { + submissionState: SUBMISSION_STATE; + searchResults: SearchResult[]; + nhsNumber: string; + onViewDocument: (document: SearchResult) => void; + downloadState: SUBMISSION_STATE; + setDownloadState: Dispatch>; + role?: REPOSITORY_ROLE; +}; +const SearchResults = ({ + submissionState, + searchResults, + nhsNumber, + onViewDocument, + downloadState, + setDownloadState, + role, +}: SearchResultsProps): React.JSX.Element => { + if ( + submissionState === SUBMISSION_STATE.INITIAL || + submissionState === SUBMISSION_STATE.PENDING + ) { + return ; + } + + if (searchResults.length && nhsNumber) { + return ( + <> + + + {role === REPOSITORY_ROLE.PCSE && ( + + )} + + ); + } + + return ( +

+ + There are no documents available for this patient. + +

+ ); +}; + type PageIndexArgs = { submissionState: SUBMISSION_STATE; downloadState: SUBMISSION_STATE; @@ -221,58 +280,34 @@ const DocumentSearchResultsPageIndex = ({ }: PageIndexArgs): React.JSX.Element => { const [session] = useSessionContext(); const patientDetails = usePatient(); - const navigate = useNavigate(); + const navigate = useEnhancedNavigate(); + const config = useConfig(); const role = session.auth?.role; const canViewFiles = session.auth?.role === REPOSITORY_ROLE.GP_ADMIN || session.auth?.role === REPOSITORY_ROLE.GP_CLINICAL; - + const pageHeader = canViewFiles ? 'Lloyd George records' : 'Manage Lloyd George records'; useTitle({ pageTitle: pageHeader }); - const SearchResults = (): React.JSX.Element => { - if ( - submissionState === SUBMISSION_STATE.INITIAL || - submissionState === SUBMISSION_STATE.PENDING - ) { - return ; - } - - if (searchResults.length && nhsNumber) { - return ( - <> - - - {role === REPOSITORY_ROLE.PCSE && ( - - )} - - ); - } - - return ( -

- - There are no documents available for this patient. - -

- ); - }; - if (!session.auth) { navigate(routes.UNAUTHORISED); return <>; } + const uploadClicked = (): void => { + navigate(routes.DOCUMENT_UPLOAD); + }; + + const canUpload = + config.featureFlags.uploadDocumentIteration3Enabled && + !patientDetails?.deceased && + (role === REPOSITORY_ROLE.GP_ADMIN || role === REPOSITORY_ROLE.GP_CLINICAL) && + submissionState !== SUBMISSION_STATE.INITIAL && + submissionState !== SUBMISSION_STATE.PENDING; + return ( <> - + {canUpload && ( + + )} + + {downloadState === SUBMISSION_STATE.FAILED && ( ({ key: 'default', }), })); +vi.mock('../../styles/right-chevron-circle.svg', () => ({ + ReactComponent: () => 'svg', +})); const mockUseLocation = vi.fn(); vi.spyOn(ReactRouter, 'useLocation').mockImplementation(mockUseLocation); @@ -72,6 +76,8 @@ vi.spyOn(ReactRouter, 'useLocation').mockImplementation(mockUseLocation); const baseUrl = 'http://localhost'; const baseHeaders = { Authorization: 'Bearer token' }; +const docConfig = buildDocumentConfig(); + describe('DocumentUploadPage', (): void => { beforeEach(() => { vi.useFakeTimers(); @@ -181,7 +187,9 @@ describe('DocumentUploadPage', (): void => { await waitFor(() => { const pageTitle = screen.getByTestId('page-title'); expect(pageTitle).toBeInTheDocument(); - expect(pageTitle).toHaveTextContent('Choose Lloyd George files to upload'); + expect(pageTitle).toHaveTextContent( + docConfig.content.uploadFilesSelectTitle as string, + ); }); vi.useFakeTimers(); // Reset back to fake timers @@ -233,7 +241,7 @@ describe('DocumentUploadPage', (): void => { let docTypeLink: HTMLElement; await waitFor(() => { - docTypeLink = screen.getByTestId(`upload-${DOCUMENT_TYPE.LLOYD_GEORGE}-link`); + docTypeLink = screen.getByTestId(`upload-${DOCUMENT_TYPE.EHR}-link`); expect(docTypeLink).toBeInTheDocument(); }); diff --git a/app/src/pages/documentUploadPage/DocumentUploadPage.tsx b/app/src/pages/documentUploadPage/DocumentUploadPage.tsx index dfe2522f3..af698df94 100644 --- a/app/src/pages/documentUploadPage/DocumentUploadPage.tsx +++ b/app/src/pages/documentUploadPage/DocumentUploadPage.tsx @@ -1,7 +1,6 @@ import { AxiosError } from 'axios'; -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; -import { Outlet, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; -import { v4 as uuidv4 } from 'uuid'; +import { useEffect, useRef, useState } from 'react'; +import { Outlet, Route, Routes, useLocation } from 'react-router-dom'; import DocumentSelectFileErrorsPage from '../../components/blocks/_documentUpload/documentSelectFileErrorsPage/DocumentSelectFileErrorsPage'; import DocumentSelectOrderStage from '../../components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage'; import DocumentSelectStage from '../../components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage'; @@ -15,7 +14,6 @@ import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; import useConfig from '../../helpers/hooks/useConfig'; import usePatient from '../../helpers/hooks/usePatient'; import uploadDocuments, { - generateFileName, getDocumentStatus, uploadDocumentToS3, } from '../../helpers/requests/uploadDocuments'; @@ -36,34 +34,15 @@ import { DocumentStatusResult, UploadSession } from '../../types/generic/uploadR import { DOCUMENT_STATUS, DOCUMENT_UPLOAD_STATE, + ExistingDocument, + LocationParams, + LocationState, UploadDocument, } from '../../types/pages/UploadDocumentsPage/types'; -import documentTypesConfig from '../../config/documentTypesConfig.json'; -import { Card } from 'nhsuk-react-components'; -import { ReactComponent as RightCircleIcon } from '../../styles/right-chevron-circle.svg'; -import PatientSummary from '../../components/generic/patientSummary/PatientSummary'; -import { DOCUMENT_TYPE } from '../../helpers/utils/documentType'; - -type LocationState = { - journey?: JourneyType; - existingDocuments?: [ - { - docType: DOCUMENT_TYPE | null; - blob: Blob | null; - fileName: string | null; - documentId?: string | null; - versionId: string; - }, - ]; -}; - -type LocationParams = { - pathname: string; - state: T | undefined; - search: string; - hash: string; - key: string; -}; +import { DOCUMENT_TYPE, getConfigForDocType } from '../../helpers/utils/documentType'; +import { buildMockUploadSession } from '../../helpers/test/testBuilders'; +import { reduceDocumentsForUpload } from '../../helpers/utils/documentUpload'; +import DocumentUploadIndex from '../../components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex'; const DocumentUploadPage = (): React.JSX.Element => { const patientDetails = usePatient(); @@ -79,37 +58,27 @@ const DocumentUploadPage = (): React.JSX.Element => { const navigate = useEnhancedNavigate(); const [intervalTimer, setIntervalTimer] = useState(0); const [mergedPdfBlob, setMergedPdfBlob] = useState(); - const [journey] = useState(getJourney()); + const [journey, setJourney] = useState(getJourney()); const config = useConfig(); const interval = useRef(0); const filesErrorPageRef = useRef(false); const [documentType, setDocumentType] = useState(DOCUMENT_TYPE.LLOYD_GEORGE); + const [documentConfig, setDocumentConfig] = useState(getConfigForDocType(documentType)); const UPDATE_DOCUMENT_STATE_FREQUENCY_MILLISECONDS = 5000; const MAX_POLLING_TIME = 600000; useEffect(() => { const journeyParam = getJourney(); - if (journeyParam === 'update' && !location.state?.existingDocuments?.[0]?.blob) { - // No existing documents found for update journey - navigate(routes.SERVER_ERROR); - return; - } - - const newDocuments: Array = - location.state?.existingDocuments?.map( - (doc) => - ({ - id: doc.documentId, - file: new File([doc.blob!], doc.fileName!, { type: 'application/pdf' }), - state: DOCUMENT_UPLOAD_STATE.SELECTED, - docType: DOCUMENT_TYPE.LLOYD_GEORGE, - progress: 0, - versionId: doc.versionId, - }) as UploadDocument, - ) ?? []; + if (journeyParam === 'update') { + if (!location.state?.existingDocuments?.[0]?.blob) { + // No existing documents found for update journey + navigate(routes.SERVER_ERROR); + return; + } - setExistingDocuments(newDocuments); + updateExistingDocuments(location.state?.existingDocuments ?? []); + } }, []); useEffect(() => { @@ -128,10 +97,16 @@ const DocumentUploadPage = (): React.JSX.Element => { } const hasVirus = documents.some((d) => d.state === DOCUMENT_UPLOAD_STATE.INFECTED); - const docWithError = documents.find((d) => d.state === DOCUMENT_UPLOAD_STATE.ERROR); + const docWithError = + documents.length === 1 && + documents.find((d) => d.state === DOCUMENT_UPLOAD_STATE.ERROR); const allFinished = documents.length > 0 && - documents.every((d) => d.state === DOCUMENT_UPLOAD_STATE.SUCCEEDED); + documents.every( + (d) => + d.state === DOCUMENT_UPLOAD_STATE.SUCCEEDED || + d.state === DOCUMENT_UPLOAD_STATE.ERROR, + ); if (hasVirus && !virusReference.current) { virusReference.current = true; @@ -156,12 +131,33 @@ const DocumentUploadPage = (): React.JSX.Element => { intervalTimer, ]); + useEffect(() => { + setDocumentConfig(getConfigForDocType(documentType)); + }, [documentType]); + useEffect(() => { return (): void => { window.clearInterval(intervalTimer); }; }, [intervalTimer]); + const updateExistingDocuments = (existingDocuments: ExistingDocument[]): void => { + const newDocuments: Array = + existingDocuments?.map( + (doc) => + ({ + id: doc.documentId, + file: new File([doc.blob!], doc.fileName!, { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.SELECTED, + docType: doc.docType, + progress: 0, + versionId: doc.versionId, + }) as UploadDocument, + ) ?? []; + + setExistingDocuments(newDocuments); + }; + const uploadSingleLloydGeorgeDocument = async ( document: UploadDocument, uploadSession: UploadSession, @@ -194,60 +190,41 @@ const DocumentUploadPage = (): React.JSX.Element => { uploadSession: UploadSession, ): void => { uploadDocuments.forEach((document) => { - if (document.docType === DOCUMENT_TYPE.LLOYD_GEORGE) { - void uploadSingleLloydGeorgeDocument(document, uploadSession); - } + void uploadSingleLloydGeorgeDocument(document, uploadSession); }); }; - const getMockUploadSession = (documents: UploadDocument[]): UploadSession => { - const session: UploadSession = {}; - documents.forEach((doc) => { - session[doc.id] = { - url: 'https://dusafgdswgfew4-staging-bulk-store.s3.eu-west-2.amazonaws.com/user_upload/9730153817/91b73c0f-b5b0-49f1-acbe-b0a5752dc3df?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAXYSUA44V5SE2IC6U%2F20251028%2Feu-west-2%2Fs3%2Faws4_request&X-Amz-Date=20251028T162320Z&X-Amz-Expires=1800&X-Amz-SignedHeaders=host&X-Amz-Security-Token=FwoGZXIvYXdzEBoaDCqX56UT2MdBQk7ztCLIAWXO7781OXoLLc3gJN9UQcAZlaoEhwJl5FQfKuJvn32DAPwYhbS80rb0JGIYmF8rIqj7TKbNOfaw4t%2Bq5NUO%2FEDQLxRbSpl8%2B078%2Ba9d2pY5XbPH3u6D0nW9mzNVREwg1%2Bt02HnWp9YLdREyDO4is9Fj5P3SQRh6DydzLx3in%2BZzzwVK8prxGG%2BBYRn5cQVOKcQCtAR7NMhHhTz9GeFQxU6X5YNalZdZdRJoFmdkxkpdoFeoIozs2Kg6plZhnqbWpFIrV3GvmYTDKPfbg8gGMi2c6f%2F9IJpIscXn0RfQZYA8lr02VHjBtez0LgzKcGVXYsE666uclkspOgBxpgo%3D&X-Amz-Signature=fdf6e3d7522ab4fe80156510d1318c430d4a44170fb98924cdc231117b5eafb8', - } as any; - }); + const confirmFiles = async (): Promise => { + let reducedDocuments = [...existingDocuments, ...documents]; + const existingId = existingDocuments[0]?.id; - return session; + reducedDocuments = await reduceDocumentsForUpload( + reducedDocuments, + documentConfig, + mergedPdfBlob!, + patientDetails!, + existingId ? existingDocuments[0]?.versionId! : '1', + ); + + setDocuments(reducedDocuments); + + navigate.withParams(routeChildren.DOCUMENT_UPLOAD_UPLOADING); }; const startUpload = async (): Promise => { try { - let reducedDocuments = [...existingDocuments, ...documents]; - const existingId = existingDocuments[0]?.id; - - if ( - reducedDocuments.some((doc) => doc.docType === DOCUMENT_TYPE.LLOYD_GEORGE) && - mergedPdfBlob - ) { - reducedDocuments = reducedDocuments.filter( - (doc) => doc.docType !== DOCUMENT_TYPE.LLOYD_GEORGE, - ); - - const filename = generateFileName(patientDetails); - reducedDocuments.push({ - id: uuidv4(), - file: new File([mergedPdfBlob], filename, { type: 'application/pdf' }), - state: DOCUMENT_UPLOAD_STATE.SELECTED, - progress: 0, - docType: DOCUMENT_TYPE.LLOYD_GEORGE, - attempts: 0, - versionId: existingId ? existingDocuments[0]?.versionId : '1', - }); - } - const uploadSession: UploadSession = isLocal - ? getMockUploadSession(reducedDocuments) + ? buildMockUploadSession(documents) : await uploadDocuments({ nhsNumber, - documents: reducedDocuments, + documents: documents, baseUrl, baseHeaders, - documentReferenceId: existingId, + documentReferenceId: existingDocuments[0]?.id, }); setUploadSession(uploadSession); - const uploadingDocuments = markDocumentsAsUploading(reducedDocuments, uploadSession); + const uploadingDocuments = markDocumentsAsUploading(documents, uploadSession); setDocuments(uploadingDocuments); if (!isLocal) { @@ -364,13 +341,18 @@ const DocumentUploadPage = (): React.JSX.Element => { const getIndexElement = (): React.JSX.Element => { return config.featureFlags.uploadDocumentIteration3Enabled ? ( - + ) : ( ); }; @@ -387,6 +369,7 @@ const DocumentUploadPage = (): React.JSX.Element => { setDocuments={setDocuments} documentType={documentType} filesErrorRef={filesErrorPageRef} + documentConfig={documentConfig} /> } /> @@ -398,6 +381,8 @@ const DocumentUploadPage = (): React.JSX.Element => { setDocuments={setDocuments} setMergedPdfBlob={setMergedPdfBlob} existingDocuments={existingDocuments} + documentConfig={documentConfig} + confirmFiles={confirmFiles} /> } /> @@ -407,23 +392,38 @@ const DocumentUploadPage = (): React.JSX.Element => { } /> } + element={ + + } /> + } /> } + element={ + + } /> { }; export default DocumentUploadPage; - -type DocumentUploadIndexProps = { - setDocumentType: Dispatch>; -}; -const DocumentUploadIndex = ({ setDocumentType }: DocumentUploadIndexProps): React.JSX.Element => { - const navigate = useNavigate(); - - const documentTypeSelected = (documentType: DOCUMENT_TYPE): void => { - setDocumentType(documentType); - navigate(routeChildren.DOCUMENT_UPLOAD_SELECT_FILES); - }; - - return ( - <> -

Choose a document type to upload

- - - - - {documentTypesConfig.map((documentConfig) => ( - - - - - - documentTypeSelected( - documentConfig.snomed_code as DOCUMENT_TYPE, - ) - } - > - {documentConfig.content.upload_title} - - - - {documentConfig.content.upload_description} - - - - - - ))} - - - ); -}; diff --git a/app/src/pages/homePage/HomePage.test.tsx b/app/src/pages/homePage/HomePage.test.tsx index f61c22c85..76ea0e526 100644 --- a/app/src/pages/homePage/HomePage.test.tsx +++ b/app/src/pages/homePage/HomePage.test.tsx @@ -15,6 +15,9 @@ vi.mock('react-router-dom', async () => { }); vi.mock('../../helpers/hooks/useConfig'); +vi.mock('../../styles/right-chevron-circle.svg', () => ({ + ReactComponent: () => 'svg', +})); const mockUseConfig = useConfig as Mock; describe('HomePage', () => { @@ -29,8 +32,12 @@ describe('HomePage', () => { it('should render home page with patient search and download report', async () => { render(); - const searchPatientButton = screen.getByTestId('search-patient-btn') as HTMLAnchorElement; - const downloadReportButton = screen.getByTestId('download-report-btn') as HTMLAnchorElement; + const searchPatientButton = screen.getByTestId( + 'search-patient-btn', + ) as HTMLAnchorElement; + const downloadReportButton = screen.getByTestId( + 'download-report-btn', + ) as HTMLAnchorElement; expect(searchPatientButton).toBeInTheDocument(); expect(downloadReportButton).toBeInTheDocument(); }); diff --git a/app/src/pages/homePage/HomePage.tsx b/app/src/pages/homePage/HomePage.tsx index 9418decd7..ace8b26c8 100644 --- a/app/src/pages/homePage/HomePage.tsx +++ b/app/src/pages/homePage/HomePage.tsx @@ -74,7 +74,7 @@ const HomePage = (): React.JSX.Element => { - {config.featureFlags.uploadDocumentIteration3Enabled && + {config.featureFlags.uploadDocumentIteration3Enabled && ( @@ -93,7 +93,7 @@ const HomePage = (): React.JSX.Element => { - } + )} ); diff --git a/app/src/styles/App.scss b/app/src/styles/App.scss index 277fa3476..e76ddfbab 100644 --- a/app/src/styles/App.scss +++ b/app/src/styles/App.scss @@ -6,6 +6,7 @@ $govuk-compatibility-govukelements: true; @import 'govuk-frontend/dist/govuk/components/warning-text/warning-text'; @import 'govuk-frontend/dist/govuk/components/notification-banner/notification-banner'; @import 'govuk-frontend/dist/govuk/components/pagination/pagination'; +@import 'govuk-frontend/dist/govuk/components/accordion/accordion'; @import 'nhsuk-frontend/packages/nhsuk'; @@ -502,10 +503,6 @@ $hunit: '%'; } } - &_upload-complete { - max-width: 711px; - } - &_drag-and-drop { word-break: break-word; overflow-wrap: break-word; @@ -1315,3 +1312,4 @@ progress:not(.continuous-progress-bar) { } @import '../components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.scss'; +@import '../components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.scss'; diff --git a/app/src/types/blocks/documentReview.ts b/app/src/types/blocks/documentReview.ts index 7aa86912e..8e808f295 100644 --- a/app/src/types/blocks/documentReview.ts +++ b/app/src/types/blocks/documentReview.ts @@ -21,15 +21,15 @@ export type DocumentReviewStatusDto = { }; export enum DocumentReviewStatus { - PENDING_REVIEW = "PENDING_REVIEW", - APPROVED = "APPROVED", - APPROVED_PENDING_DOCUMENTS = "APPROVED_PENDING_DOCUMENTS", - REJECTED = "REJECTED", - AWAITING_DOCUMENTS = "AWAITING_DOCUMENTS", - REJECTED_DUPLICATE = "REJECTED_DUPLICATE", - REASSIGNED = "REASSIGNED", - REASSIGNED_PATIENT_UNKNOWN = "REASSIGNED_PATIENT_UNKNOWN", - NEVER_REVIEWED = "NEVER_REVIEWED", - REVIEW_PENDING_UPLOAD = "REVIEW_PENDING_UPLOAD", - VIRUS_SCAN_FAILED = "VIRUS_SCAN_FAILED", -} \ No newline at end of file + PENDING_REVIEW = 'PENDING_REVIEW', + APPROVED = 'APPROVED', + APPROVED_PENDING_DOCUMENTS = 'APPROVED_PENDING_DOCUMENTS', + REJECTED = 'REJECTED', + AWAITING_DOCUMENTS = 'AWAITING_DOCUMENTS', + REJECTED_DUPLICATE = 'REJECTED_DUPLICATE', + REASSIGNED = 'REASSIGNED', + REASSIGNED_PATIENT_UNKNOWN = 'REASSIGNED_PATIENT_UNKNOWN', + NEVER_REVIEWED = 'NEVER_REVIEWED', + REVIEW_PENDING_UPLOAD = 'REVIEW_PENDING_UPLOAD', + VIRUS_SCAN_FAILED = 'VIRUS_SCAN_FAILED', +} diff --git a/app/src/types/pages/UploadDocumentsPage/types.ts b/app/src/types/pages/UploadDocumentsPage/types.ts index 58b232f2b..78b6cdd82 100644 --- a/app/src/types/pages/UploadDocumentsPage/types.ts +++ b/app/src/types/pages/UploadDocumentsPage/types.ts @@ -1,6 +1,7 @@ import type { Dispatch, FormEvent, SetStateAction } from 'react'; import { UPLOAD_FILE_ERROR_TYPE } from '../../../helpers/utils/fileUploadErrorMessages'; import { DOCUMENT_TYPE } from '../../../helpers/utils/documentType'; +import { JourneyType } from '../../../helpers/utils/urlManipulations'; export type SetUploadStage = Dispatch>; export type SetUploadDocuments = Dispatch>>; @@ -45,14 +46,25 @@ export type UploadDocument = { versionId?: string; }; -export type SearchResult = { - id: string; - description: string; - type: string; - indexed: Date; - virusScanResult: string; -}; - export interface FileInputEvent extends FormEvent { target: HTMLInputElement & EventTarget; } + +export type ExistingDocument = { + docType: DOCUMENT_TYPE | null; + blob: Blob | null; + fileName: string | null; + documentId?: string | null; + versionId: string; +}; +export type LocationState = { + journey?: JourneyType; + existingDocuments?: ExistingDocument[]; +}; +export type LocationParams = { + pathname: string; + state: T | undefined; + search: string; + hash: string; + key: string; +}; diff --git a/app/vite.config.ts b/app/vite.config.ts index f61750e4e..2a47cbf26 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; import svgr from 'vite-plugin-svgr'; import commonjs from 'vite-plugin-commonjs'; +// @ts-ignore - Type issues with vite-plugin-eslint exports import eslint from 'vite-plugin-eslint'; // Custom plugin to handle SPA fallback to main.html @@ -9,12 +10,12 @@ const spaFallbackPlugin = () => { const fallbackMiddleware = (req: any, res: any, next: any) => { // Only handle GET requests that look like routes (not files) if ( - req.method === 'GET' && - req.url && - !req.url.startsWith('/assets/') && - !req.url.startsWith('/src/') && - !req.url.startsWith('/@') && - !req.url.includes('.') && + req.method === 'GET' && + req.url && + !req.url.startsWith('/assets/') && + !req.url.startsWith('/src/') && + !req.url.startsWith('/@') && + !req.url.includes('.') && req.url !== '/main.html' && req.headers.accept?.includes('text/html') ) { @@ -30,12 +31,12 @@ const spaFallbackPlugin = () => { }, configurePreviewServer(server: any) { server.middlewares.use(fallbackMiddleware); - } + }, }; }; // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => ({ base: '/', plugins: [ react(), @@ -49,7 +50,7 @@ export default defineConfig({ }, }), eslint(), - spaFallbackPlugin() + spaFallbackPlugin(), ], preview: { port: 3000, @@ -61,5 +62,6 @@ export default defineConfig({ rollupOptions: { input: './main.html', }, - } -}); + sourcemap: mode === 'development', + }, +}));