diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx index 9ab6268a1..79e8c233b 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; -import DocumentView from './DocumentView'; +import DocumentView, { DOCUMENT_VIEW_STATE } from './DocumentView'; import usePatient from '../../../../helpers/hooks/usePatient'; import useTitle from '../../../../helpers/hooks/useTitle'; import { @@ -120,6 +120,19 @@ const TestApp = ({ documentReference }: Props): React.JSX.Element => { ); }; +const TestAppViewState = ({ documentReference }: Props): React.JSX.Element => { + const history = createMemoryHistory(); + return ( + + + + ); +}; + const renderComponent = ( documentReference: DocumentReference | null = mockDocumentReference, ): void => { @@ -470,6 +483,7 @@ describe('DocumentView', () => { mockUseConfig.mockReturnValue({ featureFlags: { versionHistoryEnabled: true, + documentCorrectEnabled: true, }, }); @@ -515,4 +529,16 @@ describe('DocumentView', () => { expect(screen.queryByTestId('record-menu-card')).not.toBeInTheDocument(); }); }); + + describe('Document view state', () => { + it('renders version history view when viewState is set to VERSION_HISTORY', () => { + render( + + + , + ); + + expect(screen.getByText('Lloyd George records')).toBeInTheDocument(); + }); + }); }); diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx index 82007a4b8..4aec0d52c 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx @@ -1,13 +1,24 @@ -import { routeChildren, routes } from '../../../../types/generic/routes'; +import { BackLink, Button, Card, ChevronLeftIcon } from 'nhsuk-react-components'; +import type { MouseEvent } from 'react'; +import { useEffect } from 'react'; +import { + createSearchParams, + NavigateOptions, + To, + useLocation, + useNavigate, +} from 'react-router-dom'; +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 { useSessionContext } from '../../../../providers/sessionProvider/SessionProvider'; import { DOCUMENT_TYPE, getConfigForDocTypeGeneric, LGContentKeys, } from '../../../../helpers/utils/documentType'; import { getFormattedDate } from '../../../../helpers/utils/formatDate'; -import { DocumentReference } from '../../../../types/pages/documentSearchResultsPage/types'; +import { useSessionContext } from '../../../../providers/sessionProvider/SessionProvider'; import { ACTION_LINK_KEY, AddAction, @@ -17,31 +28,34 @@ import { ReassignAction, VersionHistoryAction, } from '../../../../types/blocks/lloydGeorgeActions'; -import { createSearchParams, NavigateOptions, To, useNavigate } from 'react-router-dom'; import { REPOSITORY_ROLE } from '../../../../types/generic/authRole'; -import RecordCard from '../../../generic/recordCard/RecordCard'; -import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/PatientSummary'; -import { Button, Card, ChevronLeftIcon } from 'nhsuk-react-components'; -import BackButton from '../../../generic/backButton/BackButton'; -import usePatient from '../../../../helpers/hooks/usePatient'; -import { useEffect } from 'react'; -import useRole from '../../../../helpers/hooks/useRole'; +import { routeChildren, routes } from '../../../../types/generic/routes'; +import { DocumentReference } from '../../../../types/pages/documentSearchResultsPage/types'; import LinkButton from '../../../generic/linkButton/LinkButton'; -import useConfig from '../../../../helpers/hooks/useConfig'; +import PatientSummary, { PatientInfo } from '../../../generic/patientSummary/PatientSummary'; +import RecordCard from '../../../generic/recordCard/RecordCard'; import Spinner from '../../../generic/spinner/Spinner'; +export enum DOCUMENT_VIEW_STATE { + DOCUMENT = 'DOCUMENT', + VERSION_HISTORY = 'VERSION_HISTORY', +} + type Props = { documentReference: DocumentReference | null; removeDocument: () => void; + viewState?: DOCUMENT_VIEW_STATE; }; const DocumentView = ({ documentReference, removeDocument, + viewState, }: Readonly): React.JSX.Element => { const [session, setUserSession] = useSessionContext(); const role = useRole(); const navigate = useNavigate(); + const { state: isActiveVersion } = useLocation(); const showMenu = role === REPOSITORY_ROLE.GP_ADMIN && !session.isFullscreen; const patientDetails = usePatient(); const config = useConfig(); @@ -52,6 +66,14 @@ const DocumentView = ({ const pageHeader = 'Lloyd George records'; useTitle({ pageTitle: pageHeader }); + const getPdfObjectUrl = (): string => { + if (documentReference?.contentType !== 'application/pdf') { + return ''; + } + + return documentReference.url ? documentReference.url : 'loading'; + }; + // Handle fullscreen changes from browser events useEffect(() => { const handleFullscreenChange = (): void => { @@ -70,7 +92,7 @@ const DocumentView = ({ return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); }; - }, [session, setUserSession]); + }, [session, setUserSession, documentReference, getPdfObjectUrl]); if (!documentReference) { navigate(routes.PATIENT_DOCUMENTS); @@ -122,7 +144,7 @@ const DocumentView = ({ }; const getLinks = (): Array => { - if (session.isFullscreen) { + if (session.isFullscreen || viewState === DOCUMENT_VIEW_STATE.VERSION_HISTORY) { return []; } @@ -182,14 +204,6 @@ const DocumentView = ({ return links.sort((a, b) => a.index - b.index); }; - const getPdfObjectUrl = (): string => { - if (documentReference.contentType !== 'application/pdf') { - return ''; - } - - return documentReference.url ? documentReference.url : 'loading'; - }; - const enableFullscreen = (): void => { if (document.fullscreenEnabled) { document.documentElement.requestFullscreen?.(); @@ -259,10 +273,16 @@ const DocumentView = ({ }, 0); }; - const GetRecordCard = (): React.JSX.Element => { + const handleRestoreVersionClick = (): void => { + // eslint-disable-next-line no-console + console.log('Restore version clicked'); // implemented by PRMP-1411 + }; + + const getRecordCard = (): React.JSX.Element => { const heading = documentConfig.content.getValueFormatString('viewDocumentTitle', { version: documentReference.version, })!; + const card = ( {!session.isFullscreen && ( <> - + , + ): Promise | void => { + e.preventDefault(); + if (viewState === DOCUMENT_VIEW_STATE.VERSION_HISTORY) { + navigate(-1); + return; + } + navigate(routes.PATIENT_DOCUMENTS); + }} + > + Go back +

{pageHeader}

)} @@ -367,10 +397,19 @@ const DocumentView = ({ + {viewState === DOCUMENT_VIEW_STATE.VERSION_HISTORY && !isActiveVersion && ( + + )} + {session.isFullscreen && showMenu && recordCardLinks()} - {documentReference.url ? : } + {documentReference.url ? getRecordCard() : } ); diff --git a/app/src/components/blocks/testPanel/TestPanel.tsx b/app/src/components/blocks/testPanel/TestPanel.tsx index 35869008b..46a6d9829 100644 --- a/app/src/components/blocks/testPanel/TestPanel.tsx +++ b/app/src/components/blocks/testPanel/TestPanel.tsx @@ -2,8 +2,8 @@ import 'react-toggle/style.css'; import { isLocal } from '../../../helpers/utils/isLocal'; import { LocalFlags, useConfigContext } from '../../../providers/configProvider/ConfigProvider'; import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; -import { FeatureFlags } from '../../../types/generic/featureFlags'; import TestToggle, { ToggleProps } from './TestToggle'; +import { FeatureFlags } from '../../../types/generic/featureFlags'; const TestPanel = (): React.JSX.Element => { const [config, setConfig] = useConfigContext(); diff --git a/app/src/config/lloydGeorgeConfig.json b/app/src/config/lloydGeorgeConfig.json index a122ee934..4819b3af5 100644 --- a/app/src/config/lloydGeorgeConfig.json +++ b/app/src/config/lloydGeorgeConfig.json @@ -46,6 +46,8 @@ "chosenToRemovePagesSubtitle": "You have chosen to remove these pages from the scanned paper notes:", "versionHistoryLinkLabel": "View version history for these notes", "versionHistoryLinkDescription": "View or restore other versions if, for example, there's a mistake in these notes.", - "searchResultDocumentTypeLabel": "Scanned paper notes V{version}" + "searchResultDocumentTypeLabel": "Scanned paper notes V{version}", + "versionHistoryHeader": "Version history for scanned paper notes", + "versionHistoryTimelineHeader": "Scanned paper notes: version {version}" } } \ No newline at end of file diff --git a/app/src/helpers/requests/getReviews.ts b/app/src/helpers/requests/getReviews.ts index f9b1e9f6d..88078fda1 100644 --- a/app/src/helpers/requests/getReviews.ts +++ b/app/src/helpers/requests/getReviews.ts @@ -18,6 +18,7 @@ import getDocument from './getDocument'; import { fileExtensionToContentType } from '../utils/fileExtensionToContentType'; import { AuthHeaders } from '../../types/blocks/authHeaders'; import { NHS_NUMBER_UNKNOWN } from '../constants/numbers'; +import { fetchBlob } from '../utils/getPdfObjectUrl'; const getReviews = async ( baseUrl: string, @@ -84,13 +85,6 @@ export type GetReviewDataResult = { aborted: boolean; }; -const fetchBlob = async (url: string): Promise => { - const { data } = await axios.get(url, { - responseType: 'blob', - }); - return data; -}; - export const getReviewData = async ({ baseUrl, baseHeaders, diff --git a/app/src/helpers/test/getMockVersionHistory.ts b/app/src/helpers/test/getMockVersionHistory.ts index 0b873ccdb..b22355cfc 100644 --- a/app/src/helpers/test/getMockVersionHistory.ts +++ b/app/src/helpers/test/getMockVersionHistory.ts @@ -53,7 +53,7 @@ export const mockDocumentVersionHistoryResponse: Bundle = { attachment: { contentType: 'application/pdf', - url: 'https://documents.example.com/2a7a270e-aa1d-532e-8648-d5d8e3defb82', + url: '/dev/testFile1.pdf', size: 3072, title: 'document_v3.pdf', creation: '2025-12-15T10:30:00Z', @@ -108,7 +108,7 @@ export const mockDocumentVersionHistoryResponse: Bundle = { attachment: { contentType: 'application/pdf', - url: 'https://documents.example.com/c889dbbf-2e3a-5860-ab90-9421b5e29b86', + url: '/dev/testFile.pdf', size: 2048, title: 'document_v2.pdf', creation: '2025-11-10T14:00:00Z', @@ -163,7 +163,7 @@ export const mockDocumentVersionHistoryResponse: Bundle = { attachment: { contentType: 'application/pdf', - url: 'https://documents.example.com/232865e2-c1b5-58c5-bc1c-9d355907b649', + url: '/dev/testFile3.pdf', size: 1024, title: 'document_v1.pdf', creation: '2025-10-01T09:00:00Z', diff --git a/app/src/helpers/utils/documentType.ts b/app/src/helpers/utils/documentType.ts index 1d9f1d63c..8822a548a 100644 --- a/app/src/helpers/utils/documentType.ts +++ b/app/src/helpers/utils/documentType.ts @@ -23,7 +23,9 @@ export type LGContentKeys = | ContentKeys | 'versionHistoryLinkLabel' | 'versionHistoryLinkDescription' - | 'searchResultDocumentTypeLabel'; + | 'searchResultDocumentTypeLabel' + | 'versionHistoryHeader' + | 'versionHistoryTimelineHeader'; /** Content keys available to Electronic Health Record documents. */ export type EhrContentKeys = ContentKeys; /** Content keys available to EHR Attachments documents. */ diff --git a/app/src/helpers/utils/fhirUtil.test.ts b/app/src/helpers/utils/fhirUtil.test.ts index 219c116a2..dea67984e 100644 --- a/app/src/helpers/utils/fhirUtil.test.ts +++ b/app/src/helpers/utils/fhirUtil.test.ts @@ -1,7 +1,13 @@ import { Bundle } from '../../types/fhirR4/bundle'; import bundleHistory1Json from '../../types/fhirR4/bundleHistory1.fhir.json'; import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; -import { getCreatedDate, getCustodianValue, getVersionId } from './fhirUtil'; +import { DOCUMENT_TYPE } from './documentType'; +import { + getCreatedDate, + getCustodianValue, + getDocumentReferenceFromFhir, + getVersionId, +} from './fhirUtil'; const buildDoc = (overrides: Partial = {}): FhirDocumentReference => ({ resourceType: 'DocumentReference', ...overrides }) as FhirDocumentReference; @@ -75,4 +81,186 @@ describe('fhirUtil', () => { expect(getCustodianValue(buildDoc({ custodian: { identifier: {} } }))).toBe(''); }); }); + + describe('getDocumentReferenceFromFhir', () => { + it('maps a fully populated FHIR DocumentReference to DocumentReference', () => { + const fhirDoc = buildDoc({ + id: 'doc-123', + date: '2024-06-15T10:00:00Z', + author: [{ identifier: { value: 'ODS999' } }], + type: { coding: [{ code: DOCUMENT_TYPE.LLOYD_GEORGE }] }, + meta: { versionId: '3' }, + content: [ + { + attachment: { + title: 'patient-record.pdf', + size: 54321, + contentType: 'application/pdf', + url: 'https://example.org/fhir/Binary/abc', + }, + }, + ], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result).toEqual({ + documentSnomedCodeType: DOCUMENT_TYPE.LLOYD_GEORGE, + id: 'doc-123', + created: '2024-06-15T10:00:00Z', + author: 'ODS999', + fileName: 'patient-record.pdf', + fileSize: 54321, + version: '3', + contentType: 'application/pdf', + url: 'https://example.org/fhir/Binary/abc', + isPdf: true, + virusScannerResult: '', + }); + }); + + it('sets isPdf to false for non-PDF content types', () => { + const fhirDoc = buildDoc({ + id: 'doc-456', + content: [ + { + attachment: { + title: 'image.png', + size: 1024, + contentType: 'image/png', + url: 'https://example.org/fhir/Binary/img', + }, + }, + ], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.isPdf).toBe(false); + expect(result.contentType).toBe('image/png'); + }); + + it('defaults fileName to empty string when attachment title is missing', () => { + const fhirDoc = buildDoc({ + id: 'doc-789', + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.fileName).toBe(''); + }); + + it('defaults fileSize to 0 when attachment size is missing', () => { + const fhirDoc = buildDoc({ + id: 'doc-size', + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.fileSize).toBe(0); + }); + + it('defaults contentType to empty string when not provided', () => { + const fhirDoc = buildDoc({ + id: 'doc-ct', + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.contentType).toBe(''); + expect(result.isPdf).toBe(false); + }); + + it('uses author value as fallback for author', () => { + const fhirDoc = buildDoc({ + id: 'doc-display', + author: [{ display: 'GP Surgery' }], + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.author).toBe('GP Surgery'); + }); + + it('defaults author to empty string when custodian is missing', () => { + const fhirDoc = buildDoc({ + id: 'doc-no-custodian', + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.author).toBe(''); + }); + + it('defaults created to empty string when date is missing', () => { + const fhirDoc = buildDoc({ + id: 'doc-no-date', + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.created).toBe(''); + }); + + it('defaults version to empty string when meta is missing', () => { + const fhirDoc = buildDoc({ + id: 'doc-no-meta', + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.version).toBe(''); + }); + + it('handles EHR document type', () => { + const fhirDoc = buildDoc({ + id: 'ehr-doc', + type: { coding: [{ code: DOCUMENT_TYPE.EHR }] }, + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.documentSnomedCodeType).toBe(DOCUMENT_TYPE.EHR); + }); + + it('maps the bundleHistory1Json fixture entry correctly', () => { + const doc = (bundleHistory1Json as unknown as Bundle).entry![0] + .resource; + + const result = getDocumentReferenceFromFhir(doc); + + expect(result).toEqual({ + documentSnomedCodeType: undefined, + id: 'LG-12345', + created: '2024-01-10T09:15:00Z', + author: 'A12345', + fileName: 'Lloyd George Record', + fileSize: 120456, + version: '1', + contentType: 'application/pdf', + url: 'https://example.org/fhir/Binary/abcd', + isPdf: true, + virusScannerResult: '', + }); + }); + + it('always sets virusScannerResult to empty string', () => { + const fhirDoc = buildDoc({ + id: 'doc-virus', + content: [{ attachment: { url: 'https://example.org/fhir/Binary/x' } }], + }); + + const result = getDocumentReferenceFromFhir(fhirDoc); + + expect(result.virusScannerResult).toBe(''); + }); + }); }); diff --git a/app/src/helpers/utils/fhirUtil.ts b/app/src/helpers/utils/fhirUtil.ts index e315f014b..a68d7f233 100644 --- a/app/src/helpers/utils/fhirUtil.ts +++ b/app/src/helpers/utils/fhirUtil.ts @@ -1,4 +1,6 @@ import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; +import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; +import { DOCUMENT_TYPE } from './documentType'; /** * Gets the version ID from a FHIR R4 DocumentReference @@ -24,3 +26,32 @@ export const getAuthorValue = (doc: FhirDocumentReference): string => doc.author?.[0]?.display ?? doc.author?.[0]?.reference ?? ''; + +export const getDocumentReferenceFromFhir = ( + fhirDocRef: FhirDocumentReference, +): DocumentReference => { + const documentSnomedCodeType = fhirDocRef.type?.coding?.[0]?.code! as DOCUMENT_TYPE; + const created = getCreatedDate(fhirDocRef); + const author = getAuthorValue(fhirDocRef); + const fileName = fhirDocRef.content?.[0]?.attachment?.title ?? ''; + const id = fhirDocRef.id!; + const fileSize = fhirDocRef.content?.[0]?.attachment?.size ?? 0; + const version = getVersionId(fhirDocRef); + const contentType = fhirDocRef.content?.[0]?.attachment?.contentType ?? ''; + const isPdf = contentType === 'application/pdf'; + let url = fhirDocRef.content?.[0]?.attachment?.url!; + + return { + documentSnomedCodeType, + id, + created, + author, + fileName, + fileSize, + version, + contentType, + url, + isPdf, + virusScannerResult: '', + }; +}; diff --git a/app/src/helpers/utils/getPdfObjectUrl.ts b/app/src/helpers/utils/getPdfObjectUrl.ts index f270a6e18..b2c85c7a3 100644 --- a/app/src/helpers/utils/getPdfObjectUrl.ts +++ b/app/src/helpers/utils/getPdfObjectUrl.ts @@ -7,9 +7,7 @@ export const getPdfObjectUrl = async ( setPdfObjectUrl: (value: SetStateAction) => void, setDownloadStage: (value: SetStateAction) => void = (): void => {}, ): Promise => { - const { data } = await axios.get(cloudFrontUrl, { - responseType: 'blob', - }); + const data = await fetchBlob(cloudFrontUrl); const objectUrl = URL.createObjectURL(data); @@ -17,3 +15,10 @@ export const getPdfObjectUrl = async ( setDownloadStage(DOWNLOAD_STAGE.SUCCEEDED); return data.size; }; + +export const fetchBlob = async (url: string): Promise => { + const { data } = await axios.get(url, { + responseType: 'blob', + }); + return data; +}; diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx index 6b368e601..8ebcba8b1 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx @@ -26,7 +26,9 @@ import useConfig from '../../helpers/hooks/useConfig'; import { buildSearchResult } from '../../helpers/test/testBuilders'; import { useSessionContext } from '../../providers/sessionProvider/SessionProvider'; import { REPOSITORY_ROLE } from '../../types/generic/authRole'; -import DocumentView from '../../components/blocks/_patientDocuments/documentView/DocumentView'; +import DocumentView, { + DOCUMENT_VIEW_STATE, +} from '../../components/blocks/_patientDocuments/documentView/DocumentView'; import getDocument, { GetDocumentResponse } from '../../helpers/requests/getDocument'; import { DOCUMENT_TYPE } from '../../helpers/utils/documentType'; import BackButton from '../../components/generic/backButton/BackButton'; @@ -194,16 +196,30 @@ const DocumentSearchResultsPage = (): React.JSX.Element => { /> } /> + + } + /> + } /> diff --git a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx index fe0e33ec7..0e03908a0 100644 --- a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx @@ -1,4 +1,5 @@ import { render, RenderResult, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { JSX } from 'react/jsx-runtime'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import useBaseAPIHeaders from '../../helpers/hooks/useBaseAPIHeaders'; @@ -8,6 +9,8 @@ import { getDocumentVersionHistoryResponse } from '../../helpers/requests/getDoc import { mockDocumentVersionHistoryResponse } from '../../helpers/test/getMockVersionHistory'; import { buildPatientDetails, buildSearchResult } from '../../helpers/test/testBuilders'; import { DOCUMENT_TYPE } from '../../helpers/utils/documentType'; +import { fetchBlob } from '../../helpers/utils/getPdfObjectUrl'; +import { routeChildren, routes } from '../../types/generic/routes'; import DocumentVersionHistoryPage from './DocumentVersionHistoryPage'; const mockNavigate = vi.fn(); @@ -32,11 +35,14 @@ vi.mock('../../helpers/hooks/useBaseAPIUrl'); vi.mock('../../helpers/hooks/useBaseAPIHeaders'); vi.mock('../../helpers/requests/getDocumentVersionHistory'); vi.mock('../../helpers/hooks/useTitle'); +vi.mock('../../helpers/utils/getPdfObjectUrl'); const mockedUsePatient = usePatient as Mock; const mockUseBaseAPIUrl = useBaseAPIUrl as Mock; const mockUseBaseAPIHeaders = useBaseAPIHeaders as Mock; const mockGetDocumentVersionHistoryResponse = getDocumentVersionHistoryResponse as Mock; +const mockFetchBlob = fetchBlob as Mock; +const mockSetDocumentReference = vi.fn(); const mockPatientDetails = buildPatientDetails(); const mockDocumentReference = buildSearchResult({ @@ -45,7 +51,12 @@ const mockDocumentReference = buildSearchResult({ }); const renderPage = (): RenderResult => - render(); + render( + , + ); describe('DocumentVersionHistoryPage', () => { beforeEach(() => { @@ -73,6 +84,33 @@ describe('DocumentVersionHistoryPage', () => { }); }); + describe('navigation', () => { + it('navigates to patient documents page when no location state is present', async () => { + mockUseLocation.mockReturnValue({ state: null }); + + render( + , + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.PATIENT_DOCUMENTS); + }); + }); + + it('navigates to server error page when the API call fails', async () => { + mockGetDocumentVersionHistoryResponse.mockRejectedValue(new Error('API error')); + + renderPage(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR); + }); + }); + }); + describe('page structure', () => { it('renders the back button', async () => { renderPage(); @@ -93,6 +131,27 @@ describe('DocumentVersionHistoryPage', () => { ).toBeInTheDocument(); }); }); + + it('renders the correct heading for an EHR document type', async () => { + const ehrDocumentReference = buildSearchResult({ + documentSnomedCodeType: DOCUMENT_TYPE.EHR, + }); + + render( + , + ); + + await waitFor(() => { + expect( + screen.getByRole('heading', { + name: /version history for electronic health record/i, + }), + ).toBeInTheDocument(); + }); + }); }); describe('version history timeline', () => { @@ -186,4 +245,109 @@ describe('DocumentVersionHistoryPage', () => { }); }); }); + + describe('null document reference', () => { + it('navigates to patient documents when documentReference is null', () => { + render( + , + ); + + expect(mockNavigate).toHaveBeenCalledWith(routes.PATIENT_DOCUMENTS); + }); + }); + + describe('error handling', () => { + it('navigates to server error when the version history API call fails', async () => { + mockGetDocumentVersionHistoryResponse.mockRejectedValue(new Error('API error')); + + renderPage(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR); + }); + }); + }); + + describe('handleViewVersion', () => { + it('navigates to version history view when clicking View on the current version', async () => { + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-3')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-3')); + + await waitFor(() => { + expect(mockSetDocumentReference).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith( + routeChildren.DOCUMENT_VIEW_VERSION_HISTORY, + { state: true }, + ); + }); + }); + + it('navigates to version history view when clicking View on an older version', async () => { + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-2')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-2')); + + await waitFor(() => { + expect(mockSetDocumentReference).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith( + routeChildren.DOCUMENT_VIEW_VERSION_HISTORY, + { state: undefined }, + ); + }); + }); + + it('fetches blob and sets document reference URL when the document has a URL', async () => { + const mockBlobUrl = 'blob:http://localhost/mock-blob'; + const mockBlob = new Blob(['test'], { type: 'application/pdf' }); + mockFetchBlob.mockResolvedValue(mockBlob); + vi.spyOn(URL, 'createObjectURL').mockReturnValue(mockBlobUrl); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-3')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-3')); + + await waitFor(() => { + expect(mockFetchBlob).toHaveBeenCalledWith('/dev/testFile1.pdf'); + expect(mockSetDocumentReference).toHaveBeenCalledWith( + expect.objectContaining({ url: mockBlobUrl }), + ); + }); + }); + + it('navigates to server error when handleViewVersion throws', async () => { + mockFetchBlob.mockRejectedValue(new Error('Blob fetch failed')); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-3')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-3')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR); + }); + }); + }); }); diff --git a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx index 3b20998d8..a2ca05bc9 100644 --- a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx @@ -10,20 +10,34 @@ import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; import usePatient from '../../helpers/hooks/usePatient'; import useTitle from '../../helpers/hooks/useTitle'; import { getDocumentVersionHistoryResponse } from '../../helpers/requests/getDocumentVersionHistory'; -import { getDocumentTypeLabel } from '../../helpers/utils/documentType'; -import { getAuthorValue, getCreatedDate, getVersionId } from '../../helpers/utils/fhirUtil'; +import { + getConfigForDocTypeGeneric, + getDocumentTypeLabel, + LGContentKeys, +} from '../../helpers/utils/documentType'; +import { + getAuthorValue, + getCreatedDate, + getDocumentReferenceFromFhir, + getVersionId, +} from '../../helpers/utils/fhirUtil'; import { getFormatDateWithAtTime } from '../../helpers/utils/formatDate'; +import { fetchBlob } from '../../helpers/utils/getPdfObjectUrl'; import { Bundle } from '../../types/fhirR4/bundle'; import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; -import { routes } from '../../types/generic/routes'; +import { routeChildren, routes } from '../../types/generic/routes'; import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; +import { AxiosError } from 'axios'; +import { errorToParams } from '../../helpers/utils/errorToParams'; type DocumentVersionHistoryPageProps = { documentReference: DocumentReference | null; + setDocumentReference: (docRef: DocumentReference) => void; }; const DocumentVersionHistoryPage = ({ documentReference, + setDocumentReference, }: DocumentVersionHistoryPageProps): React.JSX.Element => { const navigate = useNavigate(); const baseUrl = useBaseAPIUrl(); @@ -31,27 +45,30 @@ const DocumentVersionHistoryPage = ({ const versionHistoryRef = useRef(false); const patientDetails = usePatient(); const nhsNumber = patientDetails?.nhsNumber ?? ''; + const [loading, setLoading] = useState(true); + const [versionHistory, setVersionHistory] = useState | null>( + null, + ); const docTypeLabel = documentReference ? getDocumentTypeLabel(documentReference.documentSnomedCodeType) : ''; - const pageHeader = `Version history for ${docTypeLabel.toLowerCase()}`; + const docConfig = documentReference + ? getConfigForDocTypeGeneric(documentReference.documentSnomedCodeType) + : null; + const pageHeader = + docConfig?.content.getValue('versionHistoryHeader') || + `Version history for ${docTypeLabel}`; useTitle({ pageTitle: pageHeader }); - const [loading, setLoading] = useState(true); - const [versionHistory, setVersionHistory] = useState | null>( - null, - ); - useEffect(() => { if (!documentReference) { navigate(routes.PATIENT_DOCUMENTS); return; } - - const fetchVersionHistory = async (): Promise => { - if (!versionHistoryRef.current) { - versionHistoryRef.current = true; + if (!versionHistoryRef.current) { + versionHistoryRef.current = true; + const fetchVersionHistory = async (): Promise => { try { const response = await getDocumentVersionHistoryResponse({ nhsNumber, @@ -60,26 +77,62 @@ const DocumentVersionHistoryPage = ({ documentReferenceId: documentReference.id, }); setVersionHistory(response); - } catch { - navigate(routes.PATIENT_DOCUMENTS); - } finally { setLoading(false); - } - } - }; - void fetchVersionHistory(); - }, [documentReference, nhsNumber, baseUrl, baseHeaders, navigate]); + } catch (e) { + const error = e as AxiosError; + setLoading(false); + if (error.response?.status === 403) { + navigate(routes.SESSION_EXPIRED); + } else if (error.response?.status && error.response?.status >= 500) { + navigate(routes.SERVER_ERROR + errorToParams(error)); + } - if (loading) { - return ; - } + navigate(routes.SERVER_ERROR); + } + }; + void fetchVersionHistory(); + } + }, [documentReference, nhsNumber, baseUrl]); if (!documentReference) { navigate(routes.PATIENT_DOCUMENTS); return <>; } + const handleViewVersion = async ( + e: React.MouseEvent, + doc: FhirDocumentReference, + isActiveVersion?: boolean, + ): Promise => { + e.preventDefault(); + setLoading(true); + try { + const documentRef = getDocumentReferenceFromFhir(doc); + setDocumentReference({ ...documentRef, url: '' }); + + navigate(routeChildren.DOCUMENT_VIEW_VERSION_HISTORY, { state: isActiveVersion }); + + if (documentRef.url) { + const blobUrl = await fetchBlob(documentRef.url); + setDocumentReference({ ...documentRef, url: URL.createObjectURL(blobUrl) }); + } + } catch (e) { + const error = e as AxiosError; + setLoading(false); + if (error.response?.status === 403) { + navigate(routes.SESSION_EXPIRED); + } else if (error.response?.status && error.response?.status >= 500) { + navigate(routes.SERVER_ERROR + errorToParams(error)); + } + + navigate(routes.SERVER_ERROR); + } + }; + const renderVersionHistoryTimeline = (): React.JSX.Element => { + if (loading) { + return ; + } if (!versionHistory?.entry || versionHistory.entry.length === 0) { return

No version history available for this document.

; } @@ -91,14 +144,27 @@ const DocumentVersionHistoryPage = ({ return ( {sortedEntries.map((entry, index) => { - const isCurrentVersion = index === 0; - const status = isCurrentVersion + const maxVersion = versionHistory.entry?.sort( + (a, b) => + Number(getVersionId(b.resource)) - Number(getVersionId(a.resource)), + )[0]; + + if (!maxVersion) { + return <>; + } + + const isActiveVersion = entry.resource.id === maxVersion.resource.id; + const status = isActiveVersion ? TimelineStatus.Active : TimelineStatus.Inactive; const isLastItem = index === versionHistory.entry!.length - 1; const doc = entry.resource; const version = getVersionId(doc); - const heading = `${docTypeLabel}: version ${version}`; + const heading = + docConfig?.content.getValueFormatString( + 'versionHistoryTimelineHeader', + { version }, + ) || `${docTypeLabel}: version ${version}`; return ( - {isCurrentVersion && ( + {isActiveVersion && ( This is the current version shown in this patient's record @@ -125,11 +191,15 @@ const DocumentVersionHistoryPage = ({ dateUploaded={getFormatDateWithAtTime(getCreatedDate(doc))} /> - {isCurrentVersion ? ( + {isActiveVersion ? ( , + ): Promise => handleViewVersion(e, doc, isActiveVersion)} > View @@ -138,6 +208,9 @@ const DocumentVersionHistoryPage = ({ diff --git a/app/src/types/blocks/lloydGeorgeActions.ts b/app/src/types/blocks/lloydGeorgeActions.ts index 08637e2c2..3a79221db 100644 --- a/app/src/types/blocks/lloydGeorgeActions.ts +++ b/app/src/types/blocks/lloydGeorgeActions.ts @@ -84,7 +84,7 @@ export const VersionHistoryAction = ( index: 4, label: label, key: ACTION_LINK_KEY.HISTORY, - type: RECORD_ACTION.UPDATE, // This could be a different type if needed + type: RECORD_ACTION.UPDATE, unauthorised: [], onClick, showIfRecordInStorage: true, diff --git a/app/src/types/fhirR4/bundle.ts b/app/src/types/fhirR4/bundle.ts index 87be6e27b..5413cb3bd 100644 --- a/app/src/types/fhirR4/bundle.ts +++ b/app/src/types/fhirR4/bundle.ts @@ -131,12 +131,6 @@ export interface BundleEntry { fullUrl?: string; /** A resource in the bundle */ resource: T; - /** Search related information */ - search?: BundleEntrySearch; - /** Additional execution information (transaction/batch/history) */ - request?: BundleEntryRequest; - /** Results of execution (transaction/batch/history) */ - response?: BundleEntryResponse; } // ─── Bundle Resource ─────────────────────────────────────────────────────────