diff --git a/app/cypress/e2e/1-ndr-smoke-tests/gp_user_workflows/upload_lloyd_george_is_bsol_gp_admin_workflow.cy.js b/app/cypress/e2e/1-ndr-smoke-tests/gp_user_workflows/upload_lloyd_george_is_bsol_gp_admin_workflow.cy.js index b788e6d93b..3cb50a0814 100644 --- a/app/cypress/e2e/1-ndr-smoke-tests/gp_user_workflows/upload_lloyd_george_is_bsol_gp_admin_workflow.cy.js +++ b/app/cypress/e2e/1-ndr-smoke-tests/gp_user_workflows/upload_lloyd_george_is_bsol_gp_admin_workflow.cy.js @@ -6,14 +6,14 @@ const workspace = Cypress.env('WORKSPACE'); const baseUrl = Cypress.config('baseUrl'); const uploadedFilePathNames = [ - 'cypress/fixtures/lg-files/zenia_lees/1of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', - 'cypress/fixtures/lg-files/zenia_lees/2of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', - 'cypress/fixtures/lg-files/zenia_lees/3of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', + 'cypress/fixtures/lg-files/zenia_lees/1of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', + 'cypress/fixtures/lg-files/zenia_lees/2of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', + 'cypress/fixtures/lg-files/zenia_lees/3of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', ]; const uploadedFileNames = [ - '1of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', - '2of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', - '3of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', + '1of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', + '2of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', + '3of3_Lloyd_George_Record_[Zenia Ellisa LEES]_[9730153930]_[20-03-1929].pdf', ]; const bucketName = `${workspace}-lloyd-george-store`; @@ -28,108 +28,111 @@ const confirmationUrl = '/patient/document-upload/confirmation'; const activePatient = pdsPatients.activeNoUpload; describe('GP Workflow: Upload Lloyd George record', () => { - context('Upload a Lloyd George document', () => { - beforeEach(() => { - //delete any records present for the active patient - cy.deleteItemsBySecondaryKeyFromDynamoDb( - referenceTableName, - 'NhsNumberIndex', - 'NhsNumber', - activePatient.toString(), - ); - cy.deleteItemsBySecondaryKeyFromDynamoDb( - stitchTableName, - 'NhsNumberIndex', - 'NhsNumber', - activePatient.toString() - ); - uploadedFileNames.forEach((file) => { - cy.deleteFileFromS3(bucketName, file); - }); - }); - - afterEach(() => { - //clean up any records present for the active patient - cy.deleteItemsBySecondaryKeyFromDynamoDb( - referenceTableName, - 'NhsNumberIndex', - 'NhsNumber', - activePatient.toString(), - ); - cy.deleteItemsBySecondaryKeyFromDynamoDb( - stitchTableName, - 'NhsNumberIndex', - 'NhsNumber', - activePatient.toString() - ); - uploadedFileNames.forEach((file) => { - cy.deleteFileFromS3(bucketName, file); - }); - }); - - it( - '[Smoke] GP ADMIN can upload multiple files and then view a Lloyd George record for an active patient with no record', - { tags: 'smoke', defaultCommandTimeout: 20000 }, - () => { - cy.smokeLogin(Roles.SMOKE_GP_ADMIN); - - cy.navigateToPatientSearchPage(); - - cy.get('#nhs-number-input').should('exist'); - cy.get('#nhs-number-input').click(); - cy.get('#nhs-number-input').type(activePatient); - cy.getByTestId('search-submit-btn').should('exist'); - cy.getByTestId('search-submit-btn').click(); - - cy.url({ timeout: 15000 }).should('contain', patientVerifyUrl); - - cy.get('#verify-submit').should('exist'); - cy.get('#verify-submit').click(); - - cy.url().should('contain', lloydGeorgeRecordUrl); - cy.getByTestId('no-records-title').should( - 'include.text', - 'This patient does not have a Lloyd George record', - ); - cy.getByTestId('upload-patient-record-button').should('exist'); - cy.getByTestId('upload-patient-record-button').click(); - uploadedFilePathNames.forEach((file) => { - cy.getByTestId('button-input').selectFile(file, { force: true }); - var index = uploadedFilePathNames.indexOf(file); - cy.get('#selected-documents-table').should('contain', uploadedFileNames[index]); + context('Upload a Lloyd George document', () => { + beforeEach(() => { + //delete any records present for the active patient + cy.deleteItemsBySecondaryKeyFromDynamoDb( + referenceTableName, + 'NhsNumberIndex', + 'NhsNumber', + activePatient.toString(), + ); + cy.deleteItemsBySecondaryKeyFromDynamoDb( + stitchTableName, + 'NhsNumberIndex', + 'NhsNumber', + activePatient.toString(), + ); + uploadedFileNames.forEach((file) => { + cy.deleteFileFromS3(bucketName, file); + }); }); - cy.get('#continue-button').click(); - cy.url().should('contain', selectOrderUrl); - cy.get('#selected-documents-table').should('exist'); - uploadedFileNames.forEach((name) => { - cy.get('#selected-documents-table').should('contain', name); + afterEach(() => { + //clean up any records present for the active patient + cy.deleteItemsBySecondaryKeyFromDynamoDb( + referenceTableName, + 'NhsNumberIndex', + 'NhsNumber', + activePatient.toString(), + ); + cy.deleteItemsBySecondaryKeyFromDynamoDb( + stitchTableName, + 'NhsNumberIndex', + 'NhsNumber', + activePatient.toString(), + ); + uploadedFileNames.forEach((file) => { + cy.deleteFileFromS3(bucketName, file); + }); }); - cy.getByTestId('form-submit-button').click(); - cy.url().should('contain', confirmationUrl); - uploadedFileNames.forEach((name) => { - cy.get('#selected-documents-table').should('contain', name); - }); - cy.getByTestId('confirm-button').click(); - - cy.getByTestId('upload-complete-page', { timeout: 25000 }).should('exist'); - cy.getByTestId('upload-complete-page') - .should('include.text', 'You have successfully uploaded a digital Lloyd George record for'); - - cy.getByTestId('upload-complete-card').should('be.visible'); - - cy.getByTestId('home-btn').eq(1).click(); - - cy.navigateToPatientSearchPage(); - - cy.get('#nhs-number-input').type(activePatient); - cy.get('#search-submit').click(); - cy.wait(5000) - - cy.get('.patient-results-form').submit(); - - cy.get("#pdf-viewer", {timeout: 20000}).should('exist'); - }); - }); + it( + '[Smoke] GP ADMIN can upload multiple files and then view a Lloyd George record for an active patient with no record', + { tags: 'smoke', defaultCommandTimeout: 20000 }, + () => { + cy.smokeLogin(Roles.SMOKE_GP_ADMIN); + + cy.navigateToPatientSearchPage(); + + cy.get('#nhs-number-input').should('exist'); + cy.get('#nhs-number-input').click(); + cy.get('#nhs-number-input').type(activePatient); + cy.getByTestId('search-submit-btn').should('exist'); + cy.getByTestId('search-submit-btn').click(); + + cy.url({ timeout: 15000 }).should('contain', patientVerifyUrl); + + cy.get('#verify-submit').should('exist'); + cy.get('#verify-submit').click(); + + cy.url().should('contain', lloydGeorgeRecordUrl); + cy.getByTestId('no-records-title').should( + 'include.text', + 'This patient does not have a Lloyd George record', + ); + cy.getByTestId('upload-patient-record-button').should('exist'); + cy.getByTestId('upload-patient-record-button').click(); + uploadedFilePathNames.forEach((file) => { + cy.getByTestId('button-input').selectFile(file, { force: true }); + var index = uploadedFilePathNames.indexOf(file); + cy.get('#selected-documents-table').should('contain', uploadedFileNames[index]); + }); + cy.get('#continue-button').click(); + + cy.url().should('contain', selectOrderUrl); + cy.get('#selected-documents-table').should('exist'); + uploadedFileNames.forEach((name) => { + cy.get('#selected-documents-table').should('contain', name); + }); + cy.getByTestId('form-submit-button').click(); + + cy.url().should('contain', confirmationUrl); + uploadedFileNames.forEach((name) => { + cy.get('#selected-documents-table').should('contain', name); + }); + cy.getByTestId('confirm-button').click(); + + cy.getByTestId('upload-complete-page', { timeout: 25000 }).should('exist'); + cy.getByTestId('upload-complete-page').should( + 'include.text', + 'You have successfully uploaded a digital Lloyd George record for', + ); + + cy.getByTestId('upload-complete-card').should('be.visible'); + + cy.getByTestId('home-btn').eq(1).click(); + + cy.navigateToPatientSearchPage(); + + cy.get('#nhs-number-input').type(activePatient); + cy.get('#search-submit').click(); + cy.wait(5000); + + cy.get('.patient-results-form').submit(); + + cy.get('#pdf-viewer', { timeout: 20000 }).should('exist'); + }, + ); + }); }); diff --git a/app/cypress/support/patients.ts b/app/cypress/support/patients.ts index fc03e13079..39e1be9ed2 100644 --- a/app/cypress/support/patients.ts +++ b/app/cypress/support/patients.ts @@ -1,8 +1,8 @@ export const pdsPatients = { - activeUpload: 9730153817, - activeNoUpload: 9730153930, + activeUpload: 9730153817, + activeNoUpload: 9730153930, }; export const stubPatients = { - activeUpload: 9730153817, - activeNoUpload: 9000000068, + activeUpload: 9730153817, + activeNoUpload: 9000000068, }; diff --git a/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx b/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx index 2ad0939e2c..bab4120bcf 100644 --- a/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentSelectOrderStage/DocumentSelectOrderStage.tsx @@ -166,6 +166,10 @@ const DocumentSelectOrderStage = ({ if (docToRemove.position) { updatedDocList = updatedDocList.map((doc) => { + if (doc.docType !== docToRemove.docType) { + return doc; + } + if (doc.position && +doc.position > +docToRemove.position!) { doc.position = +doc.position - 1; } @@ -185,7 +189,12 @@ const DocumentSelectOrderStage = ({ position: fieldValues[documentPositionKey(doc.id)]!, })); - setDocuments(updatedDocuments); + setDocuments((previousState) => { + return previousState.map((doc) => { + const updatedDoc = updatedDocuments.find((d) => d.id === doc.id); + return updatedDoc ? updatedDoc : doc; + }); + }); }; const submitDocuments = (): void => { diff --git a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.scss b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.scss new file mode 100644 index 0000000000..fc1f15da1d --- /dev/null +++ b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.scss @@ -0,0 +1,10 @@ +.document-select-stage { + .action-buttons { + display: flex; + align-items: center; + + .continue-button { + margin-bottom: 0; + } + } +} diff --git a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx index a7f976f149..a352472957 100644 --- a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx @@ -1,4 +1,11 @@ -import { Button, Fieldset, Table, TextInput, WarningCallout } from 'nhsuk-react-components'; +import { + BackLink, + Button, + Fieldset, + Table, + TextInput, + WarningCallout, +} from 'nhsuk-react-components'; import { getDocument } from 'pdfjs-dist'; import { JSX, RefObject, useEffect, useRef, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; @@ -17,7 +24,6 @@ import { SetUploadDocuments, UploadDocument, } from '../../../../types/pages/UploadDocumentsPage/types'; -import BackButton from '../../../generic/backButton/BackButton'; import LinkButton from '../../../generic/linkButton/LinkButton'; import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/PatientSummary'; import ErrorBox from '../../../layout/errorBox/ErrorBox'; @@ -32,6 +38,9 @@ export type Props = { documentType: DOCUMENT_TYPE; filesErrorRef: RefObject; documentConfig: DOCUMENT_TYPE_CONFIG; + goToNextDocType?: () => void; + goToPreviousDocType?: () => void; + showSkiplink?: boolean; }; type UploadFilesError = ErrorMessageListItem; @@ -42,6 +51,9 @@ const DocumentSelectStage = ({ documentType, filesErrorRef, documentConfig, + goToNextDocType, + goToPreviousDocType, + showSkiplink, }: Props): JSX.Element => { const fileInputRef = useRef(null); const [noFilesSelected, setNoFilesSelected] = useState(false); @@ -196,7 +208,10 @@ const DocumentSelectStage = ({ setNoFilesSelected(sortedDocs.length === 0); - setDocuments(sortedDocs); + setDocuments((previousState) => { + const docs = previousState.filter((doc) => doc.docType !== documentType); + return [...docs, ...sortedDocs]; + }); }; const validateDocuments = (): boolean => { @@ -204,7 +219,14 @@ const DocumentSelectStage = ({ documents?.forEach((doc) => (doc.validated = true)); - setDocuments([...documents]); + setDocuments((previousState) => { + previousState.forEach((doc) => { + if (doc.docType === documentType) { + doc.validated = true; + } + }); + return [...previousState]; + }); return ( documents.length > 0 && @@ -212,18 +234,31 @@ const DocumentSelectStage = ({ ); }; + const navigateToNextStep = (): void => { + if (documentConfig.stitched) { + navigate.withParams(routeChildren.DOCUMENT_UPLOAD_SELECT_ORDER); + return; + } + + navigate.withParams(routeChildren.DOCUMENT_UPLOAD_CONFIRMATION); + }; + const continueClicked = (): void => { if (!validateDocuments()) { scrollToRef.current?.scrollIntoView({ behavior: 'smooth' }); return; } - if (documentConfig.stitched) { - navigate.withParams(routeChildren.DOCUMENT_UPLOAD_SELECT_ORDER); - return; - } + skipClicked(); + }; - navigate.withParams(routeChildren.DOCUMENT_UPLOAD_CONFIRMATION); + const skipClicked = (): void => { + if (goToNextDocType) { + goToNextDocType(); + window.scrollTo(0, 0); + } else { + navigateToNextStep(); + } }; const DocumentRow = (document: UploadDocument, index: number): JSX.Element => { @@ -299,8 +334,15 @@ const DocumentSelectStage = ({ }; return ( - <> - +
+ + goToPreviousDocType ? goToPreviousDocType() : navigate(routes.VERIFY_PATIENT) + } + data-testid="back-button" + > + Go back + {(errorDocs().length > 0 || noFilesSelected || tooManyFilesAdded) && ( )} -
+
+ {showSkiplink && goToNextDocType && documents.length === 0 && ( + + {documentConfig.content.skipDocumentLinkText} + + )}
- +
); }; diff --git a/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx index 0f2aeb9f6c..ae8de73e2b 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.tsx @@ -42,12 +42,12 @@ const DocumentUploadCompleteStage = ({ documents, documentConfig }: Props): Reac ]); useEffect(() => { - if (!docsAreInFinishedState()) { + if (!docsAreInFinishedState() || patientDetails === null) { navigate(routes.HOME); } - }, [navigate, documents]); + }, [navigate, documents, patientDetails]); - if (!docsAreInFinishedState()) { + if (!docsAreInFinishedState() || patientDetails === null) { return <>; } @@ -108,7 +108,7 @@ const DocumentUploadCompleteStage = ({ documents, documentConfig }: Props): Reac

What happens next

- {journey === 'update' && ( + {journey === 'update' && patientDetails.canManageRecord && (

You can now view the updated {documentConfig.displayName} for this patient in this service by{' '} diff --git a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx index 4a037258bf..46bdabe3e9 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.test.tsx @@ -34,6 +34,22 @@ vi.mock('react-router-dom', async () => { const mockedUseNavigate = vi.fn(); +vi.mock('./components/DocumentList', async () => { + const actual = await vi.importActual('./components/DocumentList'); + return { + ...actual, + default: ({ documents }: { documents: UploadDocument[] }): React.JSX.Element => { + return ( +

+ Document List with + {documents.length} + documents +
+ ); + }, + }; +}); + vi.mock('../documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview', () => ({ default: ({ documents, @@ -67,7 +83,7 @@ let history = createMemoryHistory({ let docConfig = buildDocumentConfig(); const mockConfirmFiles = vi.fn(); -describe('DocumentUploadCompleteStage', () => { +describe('DocumentUploadConfirmStage', () => { beforeEach(() => { vi.mocked(usePatient).mockReturnValue(patientDetails); @@ -82,7 +98,7 @@ describe('DocumentUploadCompleteStage', () => { renderApp(history, 1); await waitFor(async () => { - expect(screen.getByText('Check your files before uploading')).toBeInTheDocument(); + expect(screen.getByText('Check files are for the correct patient')).toBeInTheDocument(); }); }); @@ -96,15 +112,6 @@ describe('DocumentUploadCompleteStage', () => { }); }); - it('should render pagination when doc count is high enough', async () => { - renderApp(history, 15); - - await waitFor(async () => { - expect(await screen.findByTestId('page-1-button')).toBeInTheDocument(); - expect(await screen.findByTestId('page-2-button')).toBeInTheDocument(); - }); - }); - it.each([ { fileCount: 3, expectedPreviewCount: 3, stitched: true }, { fileCount: 1, expectedPreviewCount: 1, stitched: false }, @@ -126,49 +133,6 @@ describe('DocumentUploadCompleteStage', () => { }, ); - 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); @@ -180,19 +144,6 @@ describe('DocumentUploadCompleteStage', () => { }); }); - it('should navigate to the file selection page when change files is clicked', async () => { - renderApp(history, 1); - - userEvent.click(await screen.findByTestId('change-files-button')); - - await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith({ - pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_FILES, - search: '', - }); - }); - }); - it('renders patient summary fields is inset', async () => { renderApp(history, 1); @@ -227,49 +178,15 @@ describe('DocumentUploadCompleteStage', () => { }); }); - it('renders correct text for update journey', async () => { + it('should still render all page elements correctly', async () => { renderApp(history, 1); await waitFor(async () => { expect( - screen.getByText( - `Files will be added to the existing ${docConfig.displayName} to create a single PDF document.`, - ), + screen.getByText('Check files are for the correct patient'), ).toBeInTheDocument(); - }); - }); - - it('should navigate with journey param when change files is clicked', async () => { - renderApp(history, 1); - - userEvent.click(await screen.findByTestId('change-files-button')); - - await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith({ - pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_FILES, - search: 'journey=update', - }); - }); - }); - - it('should still render all page elements correctly', async () => { - renderApp(history, 1); - - await waitFor(async () => { - expect(screen.getByText('Check your files before uploading')).toBeInTheDocument(); expect(screen.getByTestId('go-back-link')).toBeInTheDocument(); - expect(screen.getByTestId('change-files-button')).toBeInTheDocument(); expect(screen.getByTestId('confirm-button')).toBeInTheDocument(); - expect(screen.getByText('Files to be uploaded')).toBeInTheDocument(); - }); - }); - - it('should render pagination when doc count is high enough in update journey', async () => { - renderApp(history, 15); - - await waitFor(async () => { - expect(await screen.findByTestId('page-1-button')).toBeInTheDocument(); - expect(await screen.findByTestId('page-2-button')).toBeInTheDocument(); }); }); }); @@ -288,11 +205,7 @@ describe('DocumentUploadCompleteStage', () => { return render( - + , ); }; diff --git a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx index 849e0141f0..6fa815935f 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/DocumentUploadConfirmStage.tsx @@ -1,31 +1,20 @@ -import { Button, Table } from 'nhsuk-react-components'; +import { Button } 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 } from '../../../../types/generic/routes'; -import { useState } from 'react'; -import Pagination from '../../../generic/pagination/Pagination'; +import { useEffect, useState, useRef } from 'react'; import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/PatientSummary'; -import { getJourney, useEnhancedNavigate } from '../../../../helpers/utils/urlManipulations'; -import { DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType'; +import { DOCUMENT_TYPE, getConfigForDocType } from '../../../../helpers/utils/documentType'; import DocumentUploadLloydGeorgePreview from '../documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview'; import SpinnerButton from '../../../generic/spinnerButton/SpinnerButton'; +import DocumentList from './components/DocumentList'; type Props = { documents: UploadDocument[]; - documentConfig: DOCUMENT_TYPE_CONFIG; confirmFiles: () => void; }; -const DocumentUploadConfirmStage = ({ - documents, - documentConfig, - confirmFiles, -}: Props): React.JSX.Element => { - const [currentPage, setCurrentPage] = useState(0); - const navigate = useEnhancedNavigate(); - const pageSize = 10; - const journey = getJourney(); +const DocumentUploadConfirmStage = ({ documents, confirmFiles }: Props): React.JSX.Element => { const [stitchedBlobLoaded, setStitchedBlobLoaded] = useState(false); const [currentPreviewDocument, setCurrentPreviewDocument] = useState< UploadDocument | undefined @@ -34,26 +23,70 @@ const DocumentUploadConfirmStage = ({ ? documents.find((doc) => doc.file.type === 'application/pdf') : undefined, ); - const [processingFiles, setProcessingFiles] = useState(false); - - const multifile = documentConfig.multifileReview || documentConfig.multifileUpload; - - useTitle({ pageTitle: documentConfig.content.confirmFilesTitle as string }); + const processingFiles = useRef(false); + const [hasStitchedDocType, setHasStitchedDocType] = useState(false); + const [hasUnstitchedDocType, setHasUnstitchedDocType] = useState(false); + const [groupedDocuments, setGroupedDocuments] = useState< + { [key in DOCUMENT_TYPE]: UploadDocument[] } | null + >(null); + const documentPreviewRef = useRef(null); + + useTitle({ pageTitle: 'Check files are for the correct patient' }); + + useEffect(() => { + if (processingFiles.current) return; + + processingFiles.current = true; + + const groupedDocs = documents.reduce( + (groups, doc) => { + const type = doc.docType; + if (!groups[type]) { + groups[type] = []; + } + groups[type].push(doc); + return groups; + }, + {} as { [key in DOCUMENT_TYPE]: UploadDocument[] }, + ); + + setGroupedDocuments(groupedDocs); + + let hasStitched = false; + let hasUnstitched = false; + + Object.keys(groupedDocs).forEach((docType) => { + const documentConfig = getConfigForDocType(docType as DOCUMENT_TYPE); + if (documentConfig.stitched) { + if (!currentPreviewDocument) { + setCurrentPreviewDocument(groupedDocs[docType as DOCUMENT_TYPE][0]); + } + + hasStitched = true; + } else { + hasUnstitched = true; + } + }); - const currentItems = (): UploadDocument[] => { - const skipCount = currentPage * pageSize; - return documents.slice(skipCount, skipCount + pageSize); - }; + setHasStitchedDocType(hasStitched); + setHasUnstitchedDocType(hasUnstitched); - const totalPages = (): number => { - return Math.ceil(documents.length / pageSize); - }; + processingFiles.current = false; + }, [documents]); const getDocumentsForPreview = (): UploadDocument[] => { + if (!groupedDocuments) { + return []; + } + const docs = []; - if (documentConfig.stitched) { - docs.push(...documents); + const currentDocConfig = currentPreviewDocument + ? getConfigForDocType(currentPreviewDocument?.docType!) + : null; + + if (currentDocConfig?.stitched) { + docs.push(...groupedDocuments![currentPreviewDocument!.docType!]); } else if (currentPreviewDocument) { docs.push(currentPreviewDocument); } @@ -61,38 +94,71 @@ const DocumentUploadConfirmStage = ({ 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.`; - } + const confirmClicked = (): void => { + processingFiles.current = true; + confirmFiles(); + }; - return `Files will be combined into a single PDF document to create a ${documentConfig.displayName} record for this patient.`; - } + const setPreviewDocument = (document: UploadDocument) => { + setCurrentPreviewDocument(document); + // timeout to wait for first render before scrolling + setTimeout(() => { + documentPreviewRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 0); + }; + + const docLists = (): React.JSX.Element[] => { + if (!groupedDocuments) return []; - 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.`; + return Object.keys(groupedDocuments).map((docType) => ( + 1 && hasUnstitchedDocType} + setPreviewDocument={setPreviewDocument} + key={docType} + /> + )); }; - const confirmClicked = (): void => { - if (documentConfig.multifileZipped) { - setProcessingFiles(true); + const documentPreview = (): React.JSX.Element => { + if (!currentPreviewDocument) { + return <>; } - confirmFiles(); + + const config = getConfigForDocType(currentPreviewDocument.docType!); + + const updateStitchedBlob = config.stitched + ? (loaded: boolean): void => { + setStitchedBlobLoaded(loaded); + } + : undefined; + + const showCurrentlyViewingText = + hasUnstitchedDocType && + documents.length > 0 && + !!groupedDocuments && + Object.keys(groupedDocuments).length > 1; + + return ( +
+ +
+ ); }; return (
-

{documentConfig.content.confirmFilesTitle}

+

Check files are for the correct patient

-

- {multifile - ? 'Make sure that all files uploaded are for this patient only:' - : 'Make sure that the uploaded file is for this patient only:'} -

+

Make sure that all files uploaded are for this patient only:

@@ -100,102 +166,16 @@ const DocumentUploadConfirmStage = ({
-

{getFileActionParagraph()}

- -

File{multifile ? 's' : ''} to be uploaded

- - - - - Filename - {documentConfig.stitched && ( - - Position - - )} - {multifile && !documentConfig.stitched && Preview} - {multifile && ( - - - - )} - - - - - {currentItems().map((document: UploadDocument) => { - return ( - - -
- {document.file.name} -
-
- {documentConfig.stitched && ( - {document.position} - )} - {!documentConfig.stitched && documents.length > 1 && ( - - {document.file.type === 'application/pdf' ? ( - - ) : ( - '-' - )} - - )} - -
- ); - })} -
-
- - + {!processingFiles.current && docLists()} - {(documentConfig.stitched || currentPreviewDocument) && ( -
- { - setStitchedBlobLoaded(loaded); - }} - documentConfig={documentConfig} - /> -
- )} + {documentPreview()} - {(!documentConfig.stitched || stitchedBlobLoaded) && !processingFiles && ( + {(!hasStitchedDocType || stitchedBlobLoaded) && !processingFiles.current && ( )} - {processingFiles && ( + {processingFiles.current && ( )} - {documentConfig.stitched && !stitchedBlobLoaded && ( + {hasStitchedDocType && !stitchedBlobLoaded && ( { + const mockDocuments: UploadDocument[] = [ + { + id: '1', + file: new File(['content'], 'document1.pdf', { type: 'application/pdf' }), + position: 1, + state: DOCUMENT_UPLOAD_STATE.SELECTED, + attempts: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + }, + { + id: '2', + file: new File(['content'], 'document2.pdf', { type: 'application/pdf' }), + position: 2, + state: DOCUMENT_UPLOAD_STATE.SELECTED, + attempts: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + }, + { + id: '3', + file: new File(['content'], 'document3.jpg', { type: 'image/jpeg' }), + position: 3, + state: DOCUMENT_UPLOAD_STATE.SELECTED, + attempts: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + }, + ]; + + const mockSetPreviewDocument = vi.fn(); + + it('renders document list with correct table headers', () => { + render( + , + ); + + expect(screen.getByText('Filename')).toBeInTheDocument(); + expect(screen.getByText('Position')).toBeInTheDocument(); + expect(screen.getByText('View file')).toBeInTheDocument(); + }); + + it('renders all documents in the list', () => { + render( + , + ); + + expect(screen.getByText('document1.pdf')).toBeInTheDocument(); + expect(screen.getByText('document2.pdf')).toBeInTheDocument(); + expect(screen.getByText('document3.jpg')).toBeInTheDocument(); + }); + + it('shows position column for stitched documents', () => { + render( + , + ); + + expect(screen.getByText('Position')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('hides view file column when showViewFileColumn is false', () => { + render( + , + ); + + expect(screen.queryByText('View file')).not.toBeInTheDocument(); + }); + + it('renders View button only for PDF files', () => { + render( + , + ); + + const viewButtons = screen.getAllByText('View'); + expect(viewButtons).toHaveLength(2); + }); + + it('calls setPreviewDocument when View button is clicked', async () => { + const user = userEvent.setup(); + render( + , + ); + + const viewButton = screen.getByTestId('preview-1-button'); + await user.click(viewButton); + + expect(mockSetPreviewDocument).toHaveBeenCalledWith(mockDocuments[0]); + }); + + it('paginates documents correctly when more than 10 items', () => { + const manyDocuments: UploadDocument[] = Array.from({ length: 15 }, (_, i) => ({ + id: `${i + 1}`, + file: new File(['content'], `document${i + 1}.pdf`, { type: 'application/pdf' }), + position: i + 1, + state: DOCUMENT_UPLOAD_STATE.SELECTED, + attempts: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + })); + + render( + , + ); + + expect(screen.getByText('document1.pdf')).toBeInTheDocument(); + expect(screen.getByText('document10.pdf')).toBeInTheDocument(); + expect(screen.queryByText('document11.pdf')).not.toBeInTheDocument(); + }); + + it('renders dash for non-PDF files in view column', () => { + render( + , + ); + + const tableCells = screen.getAllByRole('cell'); + const dashCell = tableCells.find((cell) => cell.textContent === '-'); + expect(dashCell).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/components/DocumentList.tsx b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/components/DocumentList.tsx new file mode 100644 index 0000000000..199fb93f38 --- /dev/null +++ b/app/src/components/blocks/_documentUpload/documentUploadConfirmStage/components/DocumentList.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react'; +import { + DOCUMENT_TYPE, + DOCUMENT_TYPE_CONFIG, + getConfigForDocType, +} from '../../../../../helpers/utils/documentType'; +import { UploadDocument } from '../../../../../types/pages/UploadDocumentsPage/types'; +import { getJourney } from '../../../../../helpers/utils/urlManipulations'; +import { Table } from 'nhsuk-react-components'; +import Pagination from '../../../../generic/pagination/Pagination'; + +type Props = { + documents: UploadDocument[]; + docType: DOCUMENT_TYPE; + showViewFileColumn: boolean; + setPreviewDocument: (doc: UploadDocument) => void; +}; +const DocumentList = ({ + documents, + docType, + showViewFileColumn, + setPreviewDocument, +}: Props): React.JSX.Element => { + const [currentPage, setCurrentPage] = useState(0); + const documentConfig = getConfigForDocType(docType); + const pageSize = 10; + + const currentItems = (): UploadDocument[] => { + const skipCount = currentPage * pageSize; + return documents.slice(skipCount, skipCount + pageSize); + }; + + const totalPages = (): number => { + return Math.ceil(documents.length / pageSize); + }; + + const getFileActionParagraph = (config: DOCUMENT_TYPE_CONFIG): string => { + if (config.stitched) { + if (getJourney() === 'update') { + return `Files will be added to the existing ${config.displayName} to create a single PDF document.`; + } + + return `Files will be combined into a single PDF document to create a ${config.displayName} record for this patient.`; + } + + return ''; + }; + + return ( + <> +

{documentConfig.content.confirmFilesTableTitle}

+

{documentConfig.content.confirmFilesTableParagraph}

+

{getFileActionParagraph(documentConfig)}

+ + + + + Filename + {documentConfig.stitched && ( + + Position + + )} + {showViewFileColumn && View file} + + + + + {currentItems().map((document: UploadDocument) => { + return ( + + +
+ {document.file.name} +
+
+ {documentConfig.stitched && ( + {document.position} + )} + {showViewFileColumn && ( + + {document.file.type === 'application/pdf' ? ( + + ) : ( + '-' + )} + + )} + +
+ ); + })} +
+
+ + { + setCurrentPage(page); + }} + /> + + ); +}; + +export default DocumentList; diff --git a/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.tsx b/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.tsx index 12012ed675..41ed6b164b 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex.tsx @@ -143,30 +143,32 @@ const DocumentUploadIndex = ({ ) : ( - {documentTypesConfig.map((documentConfig) => ( - - - - - => - documentTypeSelected( - documentConfig.snomed_code as DOCUMENT_TYPE, - ) - } - > - {documentConfig.content.upload_title} - - - - {documentConfig.content.upload_description} - - - - - - ))} + {documentTypesConfig + .filter((doc) => doc.canUploadIndependently) + .map((documentConfig) => ( + + + + + => + documentTypeSelected( + documentConfig.snomed_code as DOCUMENT_TYPE, + ) + } + > + {documentConfig.content.upload_title} + + + + {documentConfig.content.upload_description} + + + + + + ))} )} diff --git a/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx b/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx index 35cc1ff175..79a605ed32 100644 --- a/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx +++ b/app/src/components/blocks/_documentUpload/documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview.tsx @@ -10,6 +10,7 @@ type Props = { setMergedPdfBlob?: (blob: Blob) => void; stitchedBlobLoaded?: (value: boolean) => void; documentConfig: DOCUMENT_TYPE_CONFIG; + showCurrentlyViewingText?: boolean; }; const DocumentUploadLloydGeorgePreview = ({ @@ -17,6 +18,7 @@ const DocumentUploadLloydGeorgePreview = ({ setMergedPdfBlob, stitchedBlobLoaded, documentConfig, + showCurrentlyViewingText, }: Props): JSX.Element => { const [mergedPdfUrl, setMergedPdfUrl] = useState(''); const journey = getJourney(); @@ -62,7 +64,7 @@ const DocumentUploadLloydGeorgePreview = ({ return ( <> -

{documentConfig.content.previewUploadTitle}

+

Preview your PDF files

{documentConfig.stitched ? ( <>

@@ -75,9 +77,24 @@ const DocumentUploadLloydGeorgePreview = ({ Preview may take longer to load if there are many files or if individual files are large.

+ {showCurrentlyViewingText && ( +

You are currently viewing the stitched {documentConfig.displayName}

+ )} ) : ( -

The preview is currently displaying the file: {documents[0]?.file.name}

+ <> +

+ You can preview your PDF files to check they are for the correct patient. If + some of your files are not PDFs, you will not see them in this preview. +

+

+ Preview may take longer to load if there are many files or if individual + files are large. +

+ {showCurrentlyViewingText && ( +

You are currently viewing: {documents[0]?.file.name}

+ )} + )} {documents && mergedPdfUrl && ( diff --git a/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx b/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx index 8a76bfe6f8..4fc60b7dc9 100644 --- a/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx +++ b/app/src/components/blocks/_lloydGeorge/lloydGeorgeViewRecordStage/LloydGeorgeViewRecordStage.tsx @@ -25,7 +25,7 @@ import useBaseAPIHeaders from '../../../../helpers/hooks/useBaseAPIHeaders'; import { AxiosError } from 'axios'; import { SearchResult } from '../../../../types/generic/searchResult'; import { isMock } from '../../../../helpers/utils/isLocal'; -import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType'; +import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType'; import lloydGeorgeConfig from '../../../../config/lloydGeorgeConfig.json'; export type Props = { @@ -164,7 +164,10 @@ const LloydGeorgeViewRecordStage = ({ handleSuccess([ { id: 'mock-document-id', - fileName: generateStitchedFileName(patientDetails, lloydGeorgeConfig), + fileName: generateStitchedFileName( + patientDetails, + lloydGeorgeConfig as DOCUMENT_TYPE_CONFIG, + ), version: 'mock-version-id', created: new Date().toISOString(), fileSize: 12345, diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx index 9f63905e08..b239ae8f9e 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx @@ -262,14 +262,14 @@ const DocumentView = ({ )}
- {documentReference.url - ? getRecordCard() - : ( -

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

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

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

+ )}
); diff --git a/app/src/components/generic/pagination/Pagination.tsx b/app/src/components/generic/pagination/Pagination.tsx index 60247478fe..e39620a06d 100644 --- a/app/src/components/generic/pagination/Pagination.tsx +++ b/app/src/components/generic/pagination/Pagination.tsx @@ -1,9 +1,9 @@ -import { Dispatch, SetStateAction, MouseEvent } from 'react'; +import { MouseEvent } from 'react'; export type Props = { totalPages: number; currentPage: number; - setCurrentPage: Dispatch>; + setCurrentPage: (page: number) => void; }; const Pagination = ({ totalPages, currentPage, setCurrentPage }: Props): React.JSX.Element => { diff --git a/app/src/config/documentTypesConfig.json b/app/src/config/documentTypesConfig.json index a2632482e3..36f8450e42 100644 --- a/app/src/config/documentTypesConfig.json +++ b/app/src/config/documentTypesConfig.json @@ -3,6 +3,7 @@ "name": "Scanned Paper Notes", "snomed_code": "16521000000101", "config_name": "scannedPaperNotesConfig", + "canUploadIndependently": true, "content": { "upload_title": "Scanned paper notes notes", "upload_description": "Upload and add files to a scanned paper Lloyd George record." @@ -12,6 +13,7 @@ "name": "Electronic Health Record", "snomed_code": "717301000000104", "config_name": "electronicHealthRecordConfig", + "canUploadIndependently": true, "content": { "upload_title": "Electronic health record (EHR) summary", "upload_description": "Upload the summary file of an electronic health record. You might also call these a 'journal' or 'practice notes'. Upload this suymmary if, for example, GP2GP fails." @@ -21,6 +23,7 @@ "name": "Electronic Health Record Attachments", "snomed_code": "24511000000107", "config_name": "electronicHealthRecordAttachmentsConfig", + "canUploadIndependently": false, "content": { "upload_title": "Attachments to an electronic health record", "upload_description": "Upload other files that are part of the patient's EHR on your clinical system. For example, letters, laboratory reports and imaging. Upload these files if, for example GP2GP fails." diff --git a/app/src/config/electronicHealthRecordAttachmentsConfig.json b/app/src/config/electronicHealthRecordAttachmentsConfig.json index 952de8ec3d..ec0277d850 100644 --- a/app/src/config/electronicHealthRecordAttachmentsConfig.json +++ b/app/src/config/electronicHealthRecordAttachmentsConfig.json @@ -24,8 +24,11 @@ "chooseFilesButtonLabel": "Choose files", "chooseFilesWarningText": "", "confirmFilesTitle": "Check files are for the correct patient", + "confirmFilesTableTitle": "Attachments to this EHR to upload", + "confirmFilesTableParagraph": "You can upload files in any format but you can only view PDF files in this service.", "beforeYouUploadTitle": "Before you upload", "previewUploadTitle": "Preview electronic health record attachment", - "uploadFilesExtraParagraph": "" + "uploadFilesExtraParagraph": "", + "skipDocumentLinkText": "Continue without uploading any EHR attachments" } } \ No newline at end of file diff --git a/app/src/config/electronicHealthRecordConfig.json b/app/src/config/electronicHealthRecordConfig.json index 909a0377af..75bff4cec1 100644 --- a/app/src/config/electronicHealthRecordConfig.json +++ b/app/src/config/electronicHealthRecordConfig.json @@ -26,8 +26,11 @@ "chooseFilesButtonLabel": "Choose PDF file", "chooseFilesWarningText": "EHR warning text", "confirmFilesTitle": "Check file is for the correct patient", + "confirmFilesTableTitle": "Electronic health record (EHR) to upload", + "confirmFilesTableParagraph": "", "beforeYouUploadTitle": "Before you upload", "previewUploadTitle": "Preview this electronic health record", - "uploadFilesExtraParagraph": "" + "uploadFilesExtraParagraph": "", + "skipDocumentLinkText": "Continue without uploading an EHR summary" } } \ No newline at end of file diff --git a/app/src/config/lloydGeorgeConfig.json b/app/src/config/lloydGeorgeConfig.json index 08d3a32a95..c6f1e4e757 100644 --- a/app/src/config/lloydGeorgeConfig.json +++ b/app/src/config/lloydGeorgeConfig.json @@ -28,6 +28,8 @@ "chooseFilesButtonLabel": "Choose PDF files", "chooseFilesWarningText": "", "confirmFilesTitle": "Check your files before uploading", + "confirmFilesTableTitle": "Scanned paper notes to upload", + "confirmFilesTableParagraph": "", "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." diff --git a/app/src/helpers/requests/downloadReport.test.ts b/app/src/helpers/requests/downloadReport.test.ts index f7e23fbd0d..0001848c52 100644 --- a/app/src/helpers/requests/downloadReport.test.ts +++ b/app/src/helpers/requests/downloadReport.test.ts @@ -55,7 +55,7 @@ describe('downloadReport', () => { expect(getSpy).toHaveBeenCalledWith(args.baseUrl + report.endpoint, { headers: args.baseHeaders, - params: { outputFileFormat: args.fileType, odsReportType: "PATIENT" }, + params: { outputFileFormat: args.fileType, odsReportType: 'PATIENT' }, }); expect(mockAnchor.setAttribute).toHaveBeenCalledWith('download', ''); @@ -90,7 +90,7 @@ describe('downloadReport', () => { expect(errorCode).toBe(404); expect(getSpy).toHaveBeenCalledWith(args.baseUrl + report.endpoint, { headers: args.baseHeaders, - params: { outputFileFormat: args.fileType, odsReportType: "PATIENT" }, + params: { outputFileFormat: args.fileType, odsReportType: 'PATIENT' }, }); }); }); diff --git a/app/src/helpers/requests/downloadReport.ts b/app/src/helpers/requests/downloadReport.ts index 0a45f55bfe..25a213e431 100644 --- a/app/src/helpers/requests/downloadReport.ts +++ b/app/src/helpers/requests/downloadReport.ts @@ -23,7 +23,7 @@ const downloadReport = async ({ report, fileType, baseUrl, baseHeaders }: Args): }, params: { outputFileFormat: fileType, - odsReportType: "PATIENT" + odsReportType: 'PATIENT', }, }); diff --git a/app/src/helpers/requests/uploadDocument.test.ts b/app/src/helpers/requests/uploadDocument.test.ts index b764d3c015..ba1d6fbb4f 100644 --- a/app/src/helpers/requests/uploadDocument.test.ts +++ b/app/src/helpers/requests/uploadDocument.test.ts @@ -613,7 +613,10 @@ describe('Upload Document Requests', () => { birthDate: '1990-05-15', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); expect(result).toBe( '1of1_Lloyd_George_Record_[John Michael SMITH]_[1234567890]_[15-05-1990].pdf', @@ -628,9 +631,14 @@ describe('Upload Document Requests', () => { birthDate: '1985-12-25', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); - expect(result).toBe('1of1_Lloyd_George_Record_[Jane DOE]_[0987654321]_[25-12-1985].pdf'); + expect(result).toBe( + '1of1_Lloyd_George_Record_[Jane DOE]_[0987654321]_[25-12-1985].pdf', + ); }); it('handles special characters in given name by replacing them with dashes', () => { @@ -641,7 +649,10 @@ describe('Upload Document Requests', () => { birthDate: '1975-03-10', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); expect(result).toBe( "1of1_Lloyd_George_Record_[Mary-Jane O'Connor SMITH-JONES]_[1111222233]_[10-03-1975].pdf", @@ -656,7 +667,10 @@ describe('Upload Document Requests', () => { birthDate: '2000-01-01', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); expect(result).toBe( '1of1_Lloyd_George_Record_[Test-Name- SAMPLE*FAMILY]_[5555666677]_[01-01-2000].pdf', @@ -671,9 +685,14 @@ describe('Upload Document Requests', () => { birthDate: '1965-07-20', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); - expect(result).toBe('1of1_Lloyd_George_Record_[ ONLYFAMILY]_[9999888877]_[20-07-1965].pdf'); + expect(result).toBe( + '1of1_Lloyd_George_Record_[ ONLYFAMILY]_[9999888877]_[20-07-1965].pdf', + ); }); it('handles birth date with single digit day and month', () => { @@ -684,9 +703,14 @@ describe('Upload Document Requests', () => { birthDate: '1992-02-05', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); - expect(result).toBe('1of1_Lloyd_George_Record_[Alex WILSON]_[1122334455]_[05-02-1992].pdf'); + expect(result).toBe( + '1of1_Lloyd_George_Record_[Alex WILSON]_[1122334455]_[05-02-1992].pdf', + ); }); it('throws an error when patient details is null', () => { @@ -703,7 +727,10 @@ describe('Upload Document Requests', () => { birthDate: '1980-06-15', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); expect(result).toBe( '1of1_Lloyd_George_Record_[Test-Name-With-Various-Characters-With-More---And-Finally- NORMALFAMILY]_[1234567890]_[15-06-1980].pdf', @@ -718,7 +745,10 @@ describe('Upload Document Requests', () => { birthDate: '1995-09-30', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); expect(result).toBe( '1of1_Lloyd_George_Record_[Supercalifragilisticexpialidocious AnExtremelyLongMiddleName ANEXTREMELYLONGFAMILYNAMETHATGOESONANDON]_[1111111111]_[30-09-1995].pdf', @@ -733,9 +763,14 @@ describe('Upload Document Requests', () => { birthDate: 'invalid-date', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); - expect(result).toBe('1of1_Lloyd_George_Record_[Test USER]_[1234567890]_[NaN-NaN-NaN].pdf'); + expect(result).toBe( + '1of1_Lloyd_George_Record_[Test USER]_[1234567890]_[NaN-NaN-NaN].pdf', + ); }); it('handles whitespace in names correctly', () => { @@ -746,7 +781,10 @@ describe('Upload Document Requests', () => { birthDate: '1990-01-01', }); - const result = generateStitchedFileName(patientDetails, docConfig as DOCUMENT_TYPE_CONFIG); + const result = generateStitchedFileName( + patientDetails, + docConfig as DOCUMENT_TYPE_CONFIG, + ); expect(result).toBe( '1of1_Lloyd_George_Record_[ John Michael SMITH ]_[1234567890]_[01-01-1990].pdf', diff --git a/app/src/helpers/requests/uploadDocuments.ts b/app/src/helpers/requests/uploadDocuments.ts index 2d90e811db..8977a4b05c 100644 --- a/app/src/helpers/requests/uploadDocuments.ts +++ b/app/src/helpers/requests/uploadDocuments.ts @@ -3,11 +3,7 @@ import { endpoints } from '../../types/generic/endpoints'; import { DOCUMENT_UPLOAD_STATE, UploadDocument } from '../../types/pages/UploadDocumentsPage/types'; import axios, { AxiosError } from 'axios'; -import { - DocumentStatusResult, - S3Upload, - UploadSession, -} from '../../types/generic/uploadResult'; +import { DocumentStatusResult, S3Upload, UploadSession } from '../../types/generic/uploadResult'; import { Dispatch, SetStateAction } from 'react'; import { extractUploadSession, setSingleDocument } from '../utils/uploadDocumentHelpers'; import { PatientDetails } from '../../types/generic/patientDetails'; diff --git a/app/src/helpers/test/testBuilders.ts b/app/src/helpers/test/testBuilders.ts index bc0f288f76..63069ffd65 100644 --- a/app/src/helpers/test/testBuilders.ts +++ b/app/src/helpers/test/testBuilders.ts @@ -40,6 +40,7 @@ const buildPatientDetails = (patientDetailsOverride?: Partial): restricted: false, active: true, deceased: false, + canManageRecord: true, ...patientDetailsOverride, }; @@ -178,10 +179,9 @@ const buildDocumentConfig = ( configOverride?: Partial, ): DOCUMENT_TYPE_CONFIG => { return { - snomedCode: '16521000000101', + snomedCode: DOCUMENT_TYPE.LLOYD_GEORGE, displayName: 'Scanned Paper Notes', canBeUpdated: true, - associatedSnomed: '', multifileUpload: true, multifileZipped: false, multifileReview: true, diff --git a/app/src/helpers/utils/documentType.ts b/app/src/helpers/utils/documentType.ts index 157addc2c6..ddb98f2dea 100644 --- a/app/src/helpers/utils/documentType.ts +++ b/app/src/helpers/utils/documentType.ts @@ -11,11 +11,11 @@ export enum DOCUMENT_TYPE { } export type DOCUMENT_TYPE_CONFIG = { - snomedCode: string; + snomedCode: DOCUMENT_TYPE; displayName: string; filenameOverride?: string; canBeUpdated: boolean; - associatedSnomed: string; + associatedSnomed?: DOCUMENT_TYPE; multifileUpload: boolean; multifileZipped: boolean; zippedFilename?: string; diff --git a/app/src/helpers/utils/documentUpload.test.ts b/app/src/helpers/utils/documentUpload.test.ts index 4518c8dc3f..2cbe32abe3 100644 --- a/app/src/helpers/utils/documentUpload.test.ts +++ b/app/src/helpers/utils/documentUpload.test.ts @@ -1,12 +1,27 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { reduceDocumentsForUpload } from './documentUpload'; +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { + getUploadSession, + handleDocReviewStatusResult, + handleDocStatusResult, + 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 { + DOCUMENT_STATUS, + DOCUMENT_UPLOAD_STATE, + UploadDocument, +} from '../../types/pages/UploadDocumentsPage/types'; +import { DOCUMENT_TYPE } from './documentType'; +import uploadDocuments, { generateStitchedFileName } from '../requests/uploadDocuments'; import { zipFiles } from './zip'; +import { buildMockUploadSession } from '../test/testBuilders'; +import { uploadDocumentForReview } from '../requests/documentReview'; +import * as isLocal from './isLocal'; +import { DocumentReviewStatus } from '../../types/blocks/documentReview'; +import { buildDocumentConfig } from '../test/testBuilders'; vi.mock('../requests/uploadDocuments'); +vi.mock('../requests/documentReview'); vi.mock('./zip'); vi.mock('uuid', () => ({ v4: vi.fn(() => 'mock-uuid-123'), @@ -49,25 +64,14 @@ describe('documentUpload', () => { const mockMergedPdfBlob = new Blob(['merged pdf content'], { type: 'application/pdf' }); beforeEach(() => { + import.meta.env.VITE_ENVIRONMENT = 'vitest'; + vi.spyOn(isLocal, 'isLocal', 'get').mockReturnValue(false); 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: {}, - }; + const documentConfig = buildDocumentConfig(); vi.mocked(generateStitchedFileName).mockReturnValue('stitched_file.pdf'); @@ -97,21 +101,10 @@ describe('documentUpload', () => { }); it('should return zipped document when documentConfig.multifileZipped is true', async () => { - const documentConfig: DOCUMENT_TYPE_CONFIG = { - stitched: false, + const documentConfig = buildDocumentConfig({ 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: {}, - }; + stitched: false, + }); const mockZippedBlob = new Blob(['zipped content'], { type: 'application/zip' }); vi.mocked(zipFiles).mockResolvedValue(mockZippedBlob); @@ -134,27 +127,17 @@ describe('documentUpload', () => { attempts: 0, versionId: 'version123', }); - expect(result[0].file.name).toBe('test_documents_(2).zip'); + expect(result[0].file.name).toBe( + `${documentConfig.zippedFilename}_(${mockDocuments.length}).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 = { + const documentConfig = buildDocumentConfig({ 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, @@ -170,21 +153,10 @@ describe('documentUpload', () => { }); it('should handle empty documents array for zipped files', async () => { - const documentConfig: DOCUMENT_TYPE_CONFIG = { - stitched: false, + const documentConfig = buildDocumentConfig({ 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); @@ -197,8 +169,439 @@ describe('documentUpload', () => { 'version123', ); - expect(result).toHaveLength(1); - expect(result[0].file.name).toBe('empty_documents_(0).zip'); + expect(result).toHaveLength(0); + }); + }); + + describe('getUploadSession', () => { + const baseUrl = 'https://api.example.com'; + const baseHeaders = { Authorization: 'Bearer token', 'Content-Type': 'application/json' }; + const mockSetDocuments = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return mock upload session when isLocal is true', async () => { + vi.spyOn(isLocal, 'isLocal', 'get').mockReturnValueOnce(true); + + const result = await getUploadSession( + mockPatientDetails, + baseUrl, + baseHeaders, + [], + mockDocuments, + mockSetDocuments, + ); + + expect(result).toEqual(buildMockUploadSession(mockDocuments)); + }); + + it('should call uploadDocuments when user can manage patient record', async () => { + const patientWithPermission = { + ...mockPatientDetails, + canManageRecord: true, + }; + + const mockUploadSession = { + doc1: { url: 'presigned-url-1' }, + doc2: { url: 'presigned-url-2' }, + }; + + vi.mocked(uploadDocuments).mockResolvedValue(mockUploadSession); + + const existingDocs = [{ ...mockDocuments[0], id: 'existing-doc-id' }]; + + const result = await getUploadSession( + patientWithPermission, + baseUrl, + baseHeaders, + existingDocs, + mockDocuments, + mockSetDocuments, + ); + + expect(uploadDocuments).toHaveBeenCalledWith({ + nhsNumber: patientWithPermission.nhsNumber, + documents: mockDocuments, + baseUrl, + baseHeaders, + documentReferenceId: 'existing-doc-id', + }); + expect(result).toEqual(mockUploadSession); + }); + + it('should call uploadDocumentForReview when user cannot manage patient record', async () => { + const patientWithoutPermission = { + ...mockPatientDetails, + canManageRecord: false, + }; + + const mockReviewDocs = [ + { + id: 'review-id-1', + version: 'v1', + files: [{ presignedUrl: 'review-url-1' }], + }, + { + id: 'review-id-2', + version: 'v2', + files: [{ presignedUrl: 'review-url-2' }], + }, + ]; + + vi.mocked(uploadDocumentForReview) + .mockResolvedValueOnce(mockReviewDocs[0] as any) + .mockResolvedValueOnce(mockReviewDocs[1] as any); + + const result = await getUploadSession( + patientWithoutPermission, + baseUrl, + baseHeaders, + [], + mockDocuments, + mockSetDocuments, + ); + + expect(uploadDocumentForReview).toHaveBeenCalledTimes(2); + expect(uploadDocumentForReview).toHaveBeenCalledWith({ + nhsNumber: patientWithoutPermission.nhsNumber, + document: mockDocuments[0], + baseUrl, + baseHeaders, + }); + expect(mockSetDocuments).toHaveBeenCalled(); + expect(result).toEqual({ + 'review-id-1': { url: 'review-url-1' }, + 'review-id-2': { url: 'review-url-2' }, + }); + }); + + it('should update document ids and versions when uploading for review', async () => { + const patientWithoutPermission = { + ...mockPatientDetails, + canManageRecord: false, + }; + + const mockReview = { + id: 'new-review-id', + version: 'new-version', + files: [{ presignedUrl: 'new-presigned-url' }], + }; + + vi.mocked(uploadDocumentForReview).mockResolvedValue(mockReview as any); + + await getUploadSession( + patientWithoutPermission, + baseUrl, + baseHeaders, + [], + [mockDocuments[0]], + mockSetDocuments, + ); + + const setDocumentsCall = mockSetDocuments.mock.calls[0][0]; + expect(setDocumentsCall[0].id).toBe('new-review-id'); + expect(setDocumentsCall[0].versionId).toBe('new-version'); + }); + }); + + describe('handleDocStatusResult', () => { + const mockSetDocuments = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should update document state to SUCCEEDED when status is FINAL', () => { + const documentStatusResult = { + 'doc-ref-1': { status: DOCUMENT_STATUS.FINAL as const }, + }; + + const mockDocs: UploadDocument[] = [ + { + ...mockDocuments[0], + ref: 'doc-ref-1', + state: DOCUMENT_UPLOAD_STATE.UPLOADING, + }, + ]; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn(mockDocs); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.SUCCEEDED); + }); + + handleDocStatusResult(documentStatusResult, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should update document state to INFECTED when status is INFECTED', () => { + const documentStatusResult = { + 'doc-ref-1': { status: DOCUMENT_STATUS.INFECTED as const }, + }; + + const mockDocs: UploadDocument[] = [ + { + ...mockDocuments[0], + ref: 'doc-ref-1', + state: DOCUMENT_UPLOAD_STATE.UPLOADING, + }, + ]; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn(mockDocs); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.INFECTED); + }); + + handleDocStatusResult(documentStatusResult, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should update document state to ERROR when status is NOT_FOUND', () => { + const documentStatusResult = { + 'doc-ref-1': { status: DOCUMENT_STATUS.NOT_FOUND as const, error_code: 'ERR_404' }, + }; + + const mockDocs: UploadDocument[] = [ + { + ...mockDocuments[0], + ref: 'doc-ref-1', + state: DOCUMENT_UPLOAD_STATE.UPLOADING, + }, + ]; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn(mockDocs); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.ERROR); + expect(result[0].errorCode).toBe('ERR_404'); + }); + + handleDocStatusResult(documentStatusResult, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should update document state to ERROR when status is CANCELLED', () => { + const documentStatusResult = { + 'doc-ref-1': { + status: DOCUMENT_STATUS.CANCELLED as const, + error_code: 'ERR_CANCELLED', + }, + }; + + const mockDocs: UploadDocument[] = [ + { + ...mockDocuments[0], + ref: 'doc-ref-1', + state: DOCUMENT_UPLOAD_STATE.UPLOADING, + }, + ]; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn(mockDocs); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.ERROR); + expect(result[0].errorCode).toBe('ERR_CANCELLED'); + }); + + handleDocStatusResult(documentStatusResult, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple documents with different statuses', () => { + const documentStatusResult = { + 'doc-ref-1': { status: DOCUMENT_STATUS.FINAL as const }, + 'doc-ref-2': { status: DOCUMENT_STATUS.INFECTED as const }, + 'doc-ref-3': { status: DOCUMENT_STATUS.NOT_FOUND as const, error_code: 'ERR_404' }, + }; + + const mockDocs: UploadDocument[] = [ + { ...mockDocuments[0], ref: 'doc-ref-1', state: DOCUMENT_UPLOAD_STATE.UPLOADING }, + { ...mockDocuments[1], ref: 'doc-ref-2', state: DOCUMENT_UPLOAD_STATE.UPLOADING }, + { ...mockDocuments[0], ref: 'doc-ref-3', state: DOCUMENT_UPLOAD_STATE.UPLOADING }, + ]; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn(mockDocs); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.SUCCEEDED); + expect(result[1].state).toBe(DOCUMENT_UPLOAD_STATE.INFECTED); + expect(result[2].state).toBe(DOCUMENT_UPLOAD_STATE.ERROR); + expect(result[2].errorCode).toBe('ERR_404'); + }); + + handleDocStatusResult(documentStatusResult, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should not modify documents without matching refs', () => { + const documentStatusResult = { + 'doc-ref-1': { status: DOCUMENT_STATUS.FINAL as const }, + }; + + const mockDocs: UploadDocument[] = [ + { ...mockDocuments[0], ref: 'doc-ref-2', state: DOCUMENT_UPLOAD_STATE.UPLOADING }, + ]; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn(mockDocs); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.UPLOADING); + }); + + handleDocStatusResult(documentStatusResult, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should preserve other document properties when updating state', () => { + const documentStatusResult = { + 'doc-ref-1': { status: DOCUMENT_STATUS.FINAL as const }, + }; + + const mockDocs: UploadDocument[] = [ + { + ...mockDocuments[0], + ref: 'doc-ref-1', + state: DOCUMENT_UPLOAD_STATE.UPLOADING, + progress: 75, + }, + ]; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn(mockDocs); + expect(result[0].progress).toBe(75); + expect(result[0].docType).toBe(DOCUMENT_TYPE.LLOYD_GEORGE); + expect(result[0].file).toBe(mockDocs[0].file); + }); + + handleDocStatusResult(documentStatusResult, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleDocReviewStatusResult', () => { + const mockSetDocuments = vi.fn(); + + const baseDoc: UploadDocument = { + id: 'doc1', + file: new File(['content1'], 'file1.pdf', { type: 'application/pdf' }), + state: DOCUMENT_UPLOAD_STATE.UPLOADING, + progress: 0, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + attempts: 0, + versionId: 'v1', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should update document state to SUCCEEDED when status is PENDING_REVIEW', () => { + const reviewStatusDto = { + id: 'doc1', + status: DocumentReviewStatus.PENDING_REVIEW, + }; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn([baseDoc]); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.SUCCEEDED); + }); + + // 1 = DocumentReviewStatus.PENDING_REVIEW + handleDocReviewStatusResult(reviewStatusDto as any, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should update document state to INFECTED when status is VIRUS_SCAN_FAILED', () => { + const reviewStatusDto = { + id: 'doc1', + status: DocumentReviewStatus.VIRUS_SCAN_FAILED, + }; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn([baseDoc]); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.INFECTED); + }); + + // 2 = DocumentReviewStatus.VIRUS_SCAN_FAILED + handleDocReviewStatusResult(reviewStatusDto as any, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should update document state to SCANNING when status is REVIEW_PENDING_UPLOAD', () => { + const reviewStatusDto = { + id: 'doc1', + status: DocumentReviewStatus.REVIEW_PENDING_UPLOAD, + }; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn([baseDoc]); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.SCANNING); + }); + + // 3 = DocumentReviewStatus.REVIEW_PENDING_UPLOAD + handleDocReviewStatusResult(reviewStatusDto as any, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should update document state to ERROR and set errorCode for unknown status', () => { + const reviewStatusDto = { + id: 'doc1', + status: 'unknown', + reviewReason: 'Some error reason', + }; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn([baseDoc]); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.ERROR); + expect(result[0].errorCode).toBe('Some error reason'); + }); + + handleDocReviewStatusResult(reviewStatusDto as any, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should not modify documents with non-matching ids', () => { + const reviewStatusDto = { + id: 'other-doc', + status: DocumentReviewStatus.PENDING_REVIEW, + }; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn([baseDoc]); + expect(result[0].state).toBe(DOCUMENT_UPLOAD_STATE.UPLOADING); + }); + + handleDocReviewStatusResult(reviewStatusDto as any, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); + }); + + it('should preserve other document properties when updating state', () => { + const reviewStatusDto = { + id: 'doc1', + status: DocumentReviewStatus.PENDING_REVIEW, + }; + + const docWithProps = { ...baseDoc, progress: 55, attempts: 2 }; + + mockSetDocuments.mockImplementation((updateFn) => { + const result = updateFn([docWithProps]); + expect(result[0].progress).toBe(55); + expect(result[0].attempts).toBe(2); + expect(result[0].file).toBe(docWithProps.file); + }); + + handleDocReviewStatusResult(reviewStatusDto as any, mockSetDocuments); + + expect(mockSetDocuments).toHaveBeenCalledTimes(1); }); }); }); diff --git a/app/src/helpers/utils/documentUpload.ts b/app/src/helpers/utils/documentUpload.ts index 3ac49132a7..caf38ae1d3 100644 --- a/app/src/helpers/utils/documentUpload.ts +++ b/app/src/helpers/utils/documentUpload.ts @@ -1,9 +1,24 @@ import { PatientDetails } from '../../types/generic/patientDetails'; -import { DOCUMENT_UPLOAD_STATE, UploadDocument } from '../../types/pages/UploadDocumentsPage/types'; -import { generateStitchedFileName } from '../requests/uploadDocuments'; +import { + DOCUMENT_STATUS, + DOCUMENT_UPLOAD_STATE, + UploadDocument, +} from '../../types/pages/UploadDocumentsPage/types'; +import uploadDocuments, { generateStitchedFileName } from '../requests/uploadDocuments'; import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from './documentType'; import { v4 as uuidv4 } from 'uuid'; import { zipFiles } from './zip'; +import { isLocal } from './isLocal'; +import { buildMockUploadSession } from '../test/testBuilders'; +import { DocumentStatusResult, UploadSession } from '../../types/generic/uploadResult'; +import { AuthHeaders } from '../../types/blocks/authHeaders'; +import { + DocumentReviewDto, + DocumentReviewStatus, + DocumentReviewStatusDto, +} from '../../types/blocks/documentReview'; +import { uploadDocumentForReview } from '../requests/documentReview'; +import { Dispatch, SetStateAction } from 'react'; export const reduceDocumentsForUpload = async ( documents: UploadDocument[], @@ -12,6 +27,10 @@ export const reduceDocumentsForUpload = async ( patientDetails: PatientDetails, versionId: string, ): Promise => { + if (documents.length === 0) { + return []; + } + if (documentConfig.stitched) { const filename = generateStitchedFileName(patientDetails, documentConfig); documents = [ @@ -20,7 +39,7 @@ export const reduceDocumentsForUpload = async ( file: new File([mergedPdfBlob], filename, { type: 'application/pdf' }), state: DOCUMENT_UPLOAD_STATE.SELECTED, progress: 0, - docType: documentConfig.snomedCode as DOCUMENT_TYPE, + docType: documentConfig.snomedCode, attempts: 0, versionId: versionId, }, @@ -40,7 +59,7 @@ export const reduceDocumentsForUpload = async ( }), state: DOCUMENT_UPLOAD_STATE.SELECTED, progress: 0, - docType: documentConfig.snomedCode as DOCUMENT_TYPE, + docType: documentConfig.snomedCode, attempts: 0, versionId, }, @@ -49,3 +68,125 @@ export const reduceDocumentsForUpload = async ( return documents; }; + +export const getUploadSession = async ( + patientDetails: PatientDetails, + baseUrl: string, + baseHeaders: AuthHeaders, + existingDocuments: UploadDocument[], + documents: UploadDocument[], + setDocuments: Dispatch>, +): Promise => { + if (isLocal) { + return buildMockUploadSession(documents); + } else if (patientDetails?.canManageRecord) { + return await uploadDocuments({ + nhsNumber: patientDetails.nhsNumber, + documents: documents, + baseUrl, + baseHeaders, + documentReferenceId: existingDocuments[0]?.id, + }); + } else { + const uploadSession: UploadSession = {}; + const requests: Promise[] = []; + + const reviewDocs = documents.map((document) => { + const documentReview = uploadDocumentForReview({ + nhsNumber: patientDetails.nhsNumber, + document, + baseUrl, + baseHeaders, + }); + + documentReview.then((review: DocumentReviewDto) => { + document.id = review.id; + document.versionId = review.version; + uploadSession[review.id] = { + url: review.files[0].presignedUrl, + }; + }); + + requests.push(documentReview); + + return document; + }); + + await Promise.all(requests); + + setDocuments(reviewDocs); + + return uploadSession; + } +}; + +export const handleDocStatusResult = ( + documentStatusResult: DocumentStatusResult, + setDocuments: Dispatch>, +): void => { + setDocuments((previousState) => + previousState.map((doc) => { + const docStatus = documentStatusResult[doc.ref!]; + + const updatedDoc = { + ...doc, + }; + + switch (docStatus?.status) { + case DOCUMENT_STATUS.FINAL: + updatedDoc.state = DOCUMENT_UPLOAD_STATE.SUCCEEDED; + break; + + case DOCUMENT_STATUS.INFECTED: + updatedDoc.state = DOCUMENT_UPLOAD_STATE.INFECTED; + break; + + case DOCUMENT_STATUS.NOT_FOUND: + case DOCUMENT_STATUS.CANCELLED: + updatedDoc.state = DOCUMENT_UPLOAD_STATE.ERROR; + updatedDoc.errorCode = docStatus.error_code; + break; + } + + return updatedDoc; + }), + ); +}; + +export const handleDocReviewStatusResult = ( + result: DocumentReviewStatusDto, + setDocuments: Dispatch>, +): void => { + setDocuments((previousState) => + previousState.map((doc) => { + if (doc.id !== result.id) { + return doc; + } + + const updatedDoc = { + ...doc, + }; + + switch (result.status) { + case DocumentReviewStatus.PENDING_REVIEW: + updatedDoc.state = DOCUMENT_UPLOAD_STATE.SUCCEEDED; + break; + + case DocumentReviewStatus.VIRUS_SCAN_FAILED: + updatedDoc.state = DOCUMENT_UPLOAD_STATE.INFECTED; + break; + + case DocumentReviewStatus.REVIEW_PENDING_UPLOAD: + updatedDoc.state = DOCUMENT_UPLOAD_STATE.SCANNING; + break; + + default: + updatedDoc.state = DOCUMENT_UPLOAD_STATE.ERROR; + updatedDoc.errorCode = result.reviewReason; + break; + } + + return updatedDoc; + }), + ); +}; diff --git a/app/src/pages/documentUploadPage/DocumentUploadPage.tsx b/app/src/pages/documentUploadPage/DocumentUploadPage.tsx index af698df941..486286aafd 100644 --- a/app/src/pages/documentUploadPage/DocumentUploadPage.tsx +++ b/app/src/pages/documentUploadPage/DocumentUploadPage.tsx @@ -13,10 +13,7 @@ import useBaseAPIHeaders from '../../helpers/hooks/useBaseAPIHeaders'; import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; import useConfig from '../../helpers/hooks/useConfig'; import usePatient from '../../helpers/hooks/usePatient'; -import uploadDocuments, { - getDocumentStatus, - uploadDocumentToS3, -} from '../../helpers/requests/uploadDocuments'; +import { getDocumentStatus, uploadDocumentToS3 } from '../../helpers/requests/uploadDocuments'; import { errorCodeToParams, errorToParams } from '../../helpers/utils/errorToParams'; import { isLocal, isMock } from '../../helpers/utils/isLocal'; import { @@ -30,19 +27,27 @@ import { useEnhancedNavigate, } from '../../helpers/utils/urlManipulations'; import { routeChildren, routes } from '../../types/generic/routes'; -import { DocumentStatusResult, UploadSession } from '../../types/generic/uploadResult'; +import { UploadSession } from '../../types/generic/uploadResult'; import { - DOCUMENT_STATUS, DOCUMENT_UPLOAD_STATE, ExistingDocument, LocationParams, LocationState, UploadDocument, } from '../../types/pages/UploadDocumentsPage/types'; -import { DOCUMENT_TYPE, getConfigForDocType } from '../../helpers/utils/documentType'; -import { buildMockUploadSession } from '../../helpers/test/testBuilders'; -import { reduceDocumentsForUpload } from '../../helpers/utils/documentUpload'; +import { + DOCUMENT_TYPE, + DOCUMENT_TYPE_CONFIG, + getConfigForDocType, +} from '../../helpers/utils/documentType'; +import { + getUploadSession, + handleDocReviewStatusResult, + handleDocStatusResult, + reduceDocumentsForUpload, +} from '../../helpers/utils/documentUpload'; import DocumentUploadIndex from '../../components/blocks/_documentUpload/documentUploadIndex/DocumentUploadIndex'; +import { getDocumentReviewStatus } from '../../helpers/requests/documentReview'; const DocumentUploadPage = (): React.JSX.Element => { const patientDetails = usePatient(); @@ -63,7 +68,11 @@ const DocumentUploadPage = (): React.JSX.Element => { const interval = useRef(0); const filesErrorPageRef = useRef(false); const [documentType, setDocumentType] = useState(DOCUMENT_TYPE.LLOYD_GEORGE); - const [documentConfig, setDocumentConfig] = useState(getConfigForDocType(documentType)); + const [documentConfig, setDocumentConfig] = useState( + getConfigForDocType(DOCUMENT_TYPE.LLOYD_GEORGE), + ); + const [showSkipLink, setShowSkipLink] = useState(undefined); + const [documentTypeList, setDocumentTypeList] = useState([]); const UPDATE_DOCUMENT_STATE_FREQUENCY_MILLISECONDS = 5000; const MAX_POLLING_TIME = 600000; @@ -132,7 +141,12 @@ const DocumentUploadPage = (): React.JSX.Element => { ]); useEffect(() => { - setDocumentConfig(getConfigForDocType(documentType)); + const docConfig = getConfigForDocType(documentType); + setDocumentConfig(docConfig); + if (showSkipLink === undefined && docConfig.associatedSnomed) { + setShowSkipLink(true); + setDocumentTypeList([docConfig.snomedCode, docConfig.associatedSnomed]); + } }, [documentType]); useEffect(() => { @@ -158,7 +172,30 @@ const DocumentUploadPage = (): React.JSX.Element => { setExistingDocuments(newDocuments); }; - const uploadSingleLloydGeorgeDocument = async ( + const goToNextDocType = (): void => { + const nextDocTypeIndex = documentTypeList.indexOf(documentType) + 1; + if (nextDocTypeIndex > documentTypeList.length - 1) { + return; + } + + setShowSkipLink( + nextDocTypeIndex < documentTypeList.length - 1 && + documents.some((doc) => doc.docType === documentType), + ); + setDocumentType(documentTypeList[nextDocTypeIndex]); + }; + + const goToPreviousDocType = (): void => { + const previousDocTypeIndex = documentTypeList.indexOf(documentType) - 1; + if (previousDocTypeIndex < 0) { + return; + } + + setShowSkipLink(true); + setDocumentType(documentTypeList[previousDocTypeIndex]); + }; + + const uploadSingleDocument = async ( document: UploadDocument, uploadSession: UploadSession, ): Promise => { @@ -190,21 +227,41 @@ const DocumentUploadPage = (): React.JSX.Element => { uploadSession: UploadSession, ): void => { uploadDocuments.forEach((document) => { - void uploadSingleLloydGeorgeDocument(document, uploadSession); + void uploadSingleDocument(document, uploadSession); }); }; const confirmFiles = async (): Promise => { - let reducedDocuments = [...existingDocuments, ...documents]; + const reducedDocuments: UploadDocument[] = []; const existingId = existingDocuments[0]?.id; - reducedDocuments = await reduceDocumentsForUpload( - reducedDocuments, - documentConfig, + let currentDocTypeDocuments = await reduceDocumentsForUpload( + [ + ...existingDocuments.filter((doc) => doc.docType === documentType), + ...documents.filter((doc) => doc.docType === documentType), + ], + documentConfig!, mergedPdfBlob!, patientDetails!, existingId ? existingDocuments[0]?.versionId! : '1', ); + reducedDocuments.push(...currentDocTypeDocuments); + + if (documentConfig!.associatedSnomed) { + const associatedDocuments = await reduceDocumentsForUpload( + [ + ...existingDocuments.filter( + (doc) => doc.docType === documentConfig!.associatedSnomed, + ), + ...documents.filter((doc) => doc.docType === documentConfig!.associatedSnomed), + ], + getConfigForDocType(documentConfig!.associatedSnomed), + mergedPdfBlob!, + patientDetails!, + existingId ? existingDocuments[0]?.versionId! : '1', + ); + reducedDocuments.push(...associatedDocuments); + } setDocuments(reducedDocuments); @@ -213,15 +270,14 @@ const DocumentUploadPage = (): React.JSX.Element => { const startUpload = async (): Promise => { try { - const uploadSession: UploadSession = isLocal - ? buildMockUploadSession(documents) - : await uploadDocuments({ - nhsNumber, - documents: documents, - baseUrl, - baseHeaders, - documentReferenceId: existingDocuments[0]?.id, - }); + const uploadSession: UploadSession = await getUploadSession( + patientDetails!, + baseUrl, + baseHeaders, + existingDocuments, + documents, + setDocuments, + ); setUploadSession(uploadSession); const uploadingDocuments = markDocumentsAsUploading(documents, uploadSession); @@ -252,36 +308,6 @@ const DocumentUploadPage = (): React.JSX.Element => { } }; - const handleDocStatusResult = (documentStatusResult: DocumentStatusResult): void => { - setDocuments((previousState) => - previousState.map((doc) => { - const docStatus = documentStatusResult[doc.ref!]; - - const updatedDoc = { - ...doc, - }; - - switch (docStatus?.status) { - case DOCUMENT_STATUS.FINAL: - updatedDoc.state = DOCUMENT_UPLOAD_STATE.SUCCEEDED; - break; - - case DOCUMENT_STATUS.INFECTED: - updatedDoc.state = DOCUMENT_UPLOAD_STATE.INFECTED; - break; - - case DOCUMENT_STATUS.NOT_FOUND: - case DOCUMENT_STATUS.CANCELLED: - updatedDoc.state = DOCUMENT_UPLOAD_STATE.ERROR; - updatedDoc.errorCode = docStatus.error_code; - break; - } - - return updatedDoc; - }), - ); - }; - const startIntervalTimer = (uploadDocuments: Array): number => { return window.setInterval(async () => { interval.current = interval.current + 1; @@ -314,14 +340,25 @@ const DocumentUploadPage = (): React.JSX.Element => { setDocuments(updatedDocuments); } else { try { - const documentStatusResult = await getDocumentStatus({ - documents: uploadDocuments, - baseUrl, - baseHeaders, - nhsNumber, - }); - - handleDocStatusResult(documentStatusResult); + if (patientDetails?.canManageRecord) { + const documentStatusResult = await getDocumentStatus({ + documents: uploadDocuments, + baseUrl, + baseHeaders, + nhsNumber, + }); + + handleDocStatusResult(documentStatusResult, setDocuments); + } else { + uploadDocuments.forEach(async (document) => { + void getDocumentReviewStatus({ + document, + baseUrl, + baseHeaders, + nhsNumber, + }).then((result) => handleDocReviewStatusResult(result, setDocuments)); + }); + } } catch (e) { const error = e as AxiosError; navigate(routes.SERVER_ERROR + errorToParams(error)); @@ -352,11 +389,14 @@ const DocumentUploadPage = (): React.JSX.Element => { setDocuments={setDocuments} documentType={documentType} filesErrorRef={filesErrorPageRef} - documentConfig={documentConfig} + documentConfig={documentConfig!} /> ); }; + const hasNextDocType = documentTypeList.indexOf(documentType) < documentTypeList.length - 1; + const hasPreviousDocType = documentTypeList.indexOf(documentType) > 0; + return (
@@ -365,11 +405,16 @@ const DocumentUploadPage = (): React.JSX.Element => { path={getLastURLPath(routeChildren.DOCUMENT_UPLOAD_SELECT_FILES) + '/*'} element={ doc.docType === documentType)} setDocuments={setDocuments} documentType={documentType} filesErrorRef={filesErrorPageRef} - documentConfig={documentConfig} + documentConfig={documentConfig!} + goToNextDocType={hasNextDocType ? goToNextDocType : undefined} + goToPreviousDocType={ + hasPreviousDocType ? goToPreviousDocType : undefined + } + showSkiplink={showSkipLink} /> } /> @@ -377,11 +422,11 @@ const DocumentUploadPage = (): React.JSX.Element => { path={getLastURLPath(routeChildren.DOCUMENT_UPLOAD_SELECT_ORDER) + '/*'} element={ doc.docType === documentType)} setDocuments={setDocuments} setMergedPdfBlob={setMergedPdfBlob} existingDocuments={existingDocuments} - documentConfig={documentConfig} + documentConfig={documentConfig!} confirmFiles={confirmFiles} /> } @@ -401,7 +446,6 @@ const DocumentUploadPage = (): React.JSX.Element => { element={ } @@ -412,7 +456,7 @@ const DocumentUploadPage = (): React.JSX.Element => { } /> @@ -421,7 +465,7 @@ const DocumentUploadPage = (): React.JSX.Element => { element={ } /> diff --git a/app/src/pages/patientResultPage/PatientResultPage.test.tsx b/app/src/pages/patientResultPage/PatientResultPage.test.tsx index aa80bd310f..bd3c3e5ad4 100644 --- a/app/src/pages/patientResultPage/PatientResultPage.test.tsx +++ b/app/src/pages/patientResultPage/PatientResultPage.test.tsx @@ -258,6 +258,33 @@ describe('PatientResultPage', () => { }); }); + it('navigates to upload page after user selects patient they cannot manage when role is GP Clinical and feature flag is enabled', async () => { + const patient = buildPatientDetails({ canManageRecord: false }); + + mockedUsePatient.mockReturnValue(patient); + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.GP_CLINICAL); + mockedUseConfig.mockReturnValue({ + featureFlags: { + uploadLambdaEnabled: true, + uploadArfWorkflowEnabled: false, + uploadLloydGeorgeWorkflowEnabled: true, + uploadDocumentIteration3Enabled: true, + }, + mockLocal: {}, + }); + + render(); + await userEvent.click( + screen.getByRole('button', { + name: CONFIRM_BUTTON_TEXT, + }), + ); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.DOCUMENT_UPLOAD); + }); + }); + it.each([REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.GP_CLINICAL])( "navigates to Lloyd George Record page after user selects Active patient, when role is '%s' and uploadDocumentIteration3Enabled is false", async (role) => { @@ -276,7 +303,7 @@ describe('PatientResultPage', () => { ); it.each([REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.GP_CLINICAL])( - "navigates to patient documents page after user selects Active patient, when role is '%s' and uploadDocumentIteration3Enabled is true", + "navigates to patient documents page after user selects Active patient and canManageRecord, when role is '%s' and uploadDocumentIteration3Enabled is true", async (role) => { const patient = buildPatientDetails({ active: true }); mockedUseRole.mockReturnValue(role); diff --git a/app/src/pages/patientResultPage/PatientResultPage.tsx b/app/src/pages/patientResultPage/PatientResultPage.tsx index 0709b302ed..1505cad42d 100644 --- a/app/src/pages/patientResultPage/PatientResultPage.tsx +++ b/app/src/pages/patientResultPage/PatientResultPage.tsx @@ -37,6 +37,11 @@ const PatientResultPage = (): React.JSX.Element => { return; } + if (!patientDetails.canManageRecord && featureFlags.uploadDocumentIteration3Enabled) { + navigate(routes.DOCUMENT_UPLOAD); + return; + } + if (patientDetails?.active) { navigate( featureFlags.uploadDocumentIteration3Enabled diff --git a/app/src/styles/App.scss b/app/src/styles/App.scss index e76ddfbab5..bdff8fbb68 100644 --- a/app/src/styles/App.scss +++ b/app/src/styles/App.scss @@ -559,16 +559,6 @@ $hunit: '%'; text-align: left; color: #d5281b; } - - &_upload-submission { - .lloydgeorge_link { - padding: 0; - - &:focus { - @include link-focus; - } - } - } } .input-stage-inset-text { @@ -1313,3 +1303,4 @@ progress:not(.continuous-progress-bar) { @import '../components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.scss'; @import '../components/blocks/_documentUpload/documentUploadCompleteStage/DocumentUploadCompleteStage.scss'; +@import '../components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.scss'; diff --git a/app/src/types/generic/patientDetails.ts b/app/src/types/generic/patientDetails.ts index 1905c9bced..2df0fac4d3 100644 --- a/app/src/types/generic/patientDetails.ts +++ b/app/src/types/generic/patientDetails.ts @@ -8,4 +8,5 @@ export type PatientDetails = { restricted: boolean; active: boolean; deceased: boolean; + canManageRecord?: boolean; }; diff --git a/app/src/types/generic/uploadResult.ts b/app/src/types/generic/uploadResult.ts index 42badf415c..b8f0911397 100644 --- a/app/src/types/generic/uploadResult.ts +++ b/app/src/types/generic/uploadResult.ts @@ -4,7 +4,7 @@ export type UploadSession = { export type S3Upload = { url: string; - fields: S3UploadFields; + fields?: S3UploadFields; }; export type S3UploadFields = {