diff --git a/app/src/components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage.test.tsx b/app/src/components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage.test.tsx new file mode 100644 index 000000000..d26d5b829 --- /dev/null +++ b/app/src/components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage.test.tsx @@ -0,0 +1,449 @@ +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'; +import useBaseAPIUrl from '../../../../helpers/hooks/useBaseAPIUrl'; +import usePatient from '../../../../helpers/hooks/usePatient'; +import getDocument from '../../../../helpers/requests/getDocument'; +import getDocumentSearchResults from '../../../../helpers/requests/getDocumentSearchResults'; +import { getDocumentVersionHistoryResponse } from '../../../../helpers/requests/getDocumentVersionHistory'; +import { mockDocumentVersionHistoryResponse } from '../../../../helpers/test/getMockVersionHistory'; +import { buildPatientDetails, buildSearchResult } from '../../../../helpers/test/testBuilders'; +import { DOCUMENT_TYPE } from '../../../../helpers/utils/documentType'; +import * as fhirUtil from '../../../../helpers/utils/fhirUtil'; +import { getObjectUrl } from '../../../../helpers/utils/getPdfObjectUrl'; +import { routeChildren, routes } from '../../../../types/generic/routes'; +import { DocumentReference } from '../../../../types/pages/documentSearchResultsPage/types'; +import DocumentVersionRestoreHistoryStage from './DocumentVersionRestoreHistoryStage'; + +const mockNavigate = vi.fn(); +const mockUseLocation = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: (): Mock => mockNavigate, + useLocation: (): unknown => mockUseLocation(), + Link: ({ children, to, ...props }: any): JSX.Element => ( + + {children} + + ), + }; +}); + +vi.mock('../../../../helpers/hooks/usePatient'); +vi.mock('../../../../helpers/hooks/useBaseAPIUrl'); +vi.mock('../../../../helpers/hooks/useBaseAPIHeaders'); +vi.mock('../../../../helpers/requests/getDocumentVersionHistory'); +vi.mock('../../../../helpers/requests/getDocument'); +vi.mock('../../../../helpers/requests/getDocumentSearchResults'); +vi.mock('../../../../helpers/hooks/useTitle'); +vi.mock('../../../../helpers/utils/getPdfObjectUrl'); +vi.mock('../../../../helpers/utils/isLocal', () => ({ + isLocal: false, +})); + +const mockedUsePatient = usePatient as Mock; +const mockUseBaseAPIUrl = useBaseAPIUrl as Mock; +const mockUseBaseAPIHeaders = useBaseAPIHeaders as Mock; +const mockGetDocumentVersionHistoryResponse = getDocumentVersionHistoryResponse as Mock; +const mockGetDocument = getDocument as Mock; +const mockGetDocumentSearchResults = getDocumentSearchResults as Mock; +const mockGetObjectUrl = getObjectUrl as Mock; +const mockSetDocumentReference = vi.fn(); + +const mockPatientDetails = buildPatientDetails(); +const mockDocumentReference = buildSearchResult({ + documentSnomedCodeType: DOCUMENT_TYPE.LLOYD_GEORGE, + id: 'doc-ref-123', +}); + +describe('DocumentVersionRestoreHistoryStage', () => { + const renderApp = ( + documentReference: DocumentReference | null = mockDocumentReference, + ): RenderResult => + render( + , + ); + + beforeEach(() => { + import.meta.env.VITE_ENVIRONMENT = 'vitest'; + mockedUsePatient.mockReturnValue(mockPatientDetails); + mockUseBaseAPIUrl.mockReturnValue('http://localhost'); + mockUseBaseAPIHeaders.mockReturnValue({ Authorization: 'Bearer token' }); + mockUseLocation.mockReturnValue({ + state: { documentReference: mockDocumentReference }, + }); + mockGetDocumentVersionHistoryResponse.mockResolvedValue(mockDocumentVersionHistoryResponse); + mockGetDocument.mockResolvedValue({ + url: 'http://test-url.com/doc.pdf', + contentType: 'application/pdf', + }); + mockGetDocumentSearchResults.mockResolvedValue([ + buildSearchResult({ id: 'latest-doc-ref-id' }), + ]); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('loading state', () => { + it('renders a spinner while the version history is loading', () => { + mockGetDocumentVersionHistoryResponse.mockReturnValue(new Promise(() => {})); + + renderApp(); + + expect(screen.getByText('Loading version history')).toBeInTheDocument(); + }); + }); + + describe('navigation', () => { + it('fetches version history using the latest document reference id from search', async () => { + const viewedVersionDocumentReference = buildSearchResult({ + documentSnomedCodeType: DOCUMENT_TYPE.LLOYD_GEORGE, + id: 'doc-ref-viewed-version', + }); + + mockGetDocumentSearchResults.mockResolvedValue([ + buildSearchResult({ id: 'doc-ref-from-search' }), + ]); + + renderApp(viewedVersionDocumentReference); + + await waitFor(() => { + expect(mockGetDocumentVersionHistoryResponse).toHaveBeenCalledWith( + expect.objectContaining({ + documentReferenceId: 'doc-ref-from-search', + }), + ); + }); + }); + + it('navigates to server error page when the API call fails', async () => { + mockGetDocumentVersionHistoryResponse.mockRejectedValue(new Error('API error')); + + renderApp(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR); + }); + }); + }); + + describe('page structure', () => { + it('renders the back button', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('go-back-button')).toBeInTheDocument(); + }); + }); + + it('renders the page heading with the correct document type label', async () => { + renderApp(); + + await waitFor(() => { + expect( + screen.getByRole('heading', { + name: /version history for scanned paper notes/i, + }), + ).toBeInTheDocument(); + }); + }); + + it('renders the correct heading for an EHR document type', async () => { + const ehrDocumentReference = buildSearchResult({ + documentSnomedCodeType: DOCUMENT_TYPE.EHR, + }); + + renderApp(ehrDocumentReference); + + await waitFor(() => { + expect( + screen.getByRole('heading', { + name: /version history for electronic health record/i, + }), + ).toBeInTheDocument(); + }); + }); + }); + + describe('version history timeline', () => { + it('renders "no version history" message when the bundle has an empty entries array', async () => { + mockGetDocumentVersionHistoryResponse.mockResolvedValue({ + resourceType: 'Bundle', + type: 'history', + total: 0, + entry: [], + }); + + renderApp(); + + await waitFor(() => { + expect( + screen.getByText('No version history available for this document.'), + ).toBeInTheDocument(); + }); + }); + + it('renders "no version history" message when the bundle has no entry property', async () => { + mockGetDocumentVersionHistoryResponse.mockResolvedValue({ + resourceType: 'Bundle', + type: 'history', + total: 0, + }); + + renderApp(); + + await waitFor(() => { + expect( + screen.getByText('No version history available for this document.'), + ).toBeInTheDocument(); + }); + }); + + it('renders all version history entries with correct headings', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByText('Scanned paper notes: version 3')).toBeInTheDocument(); + expect(screen.getByText('Scanned paper notes: version 2')).toBeInTheDocument(); + expect(screen.getByText('Scanned paper notes: version 1')).toBeInTheDocument(); + }); + }); + + it('shows "this is the current version" only for the first (most recent) entry', async () => { + renderApp(); + + await waitFor(() => { + const currentVersionMessages = screen.getAllByText( + "This is the current version shown in this patient's record", + ); + expect(currentVersionMessages).toHaveLength(1); + }); + }); + + it('renders a "View" link (not a button) for the current version', async () => { + renderApp(); + + await waitFor(() => { + const viewCurrentLink = screen.getByTestId('view-version-3'); + expect(viewCurrentLink.tagName.toLowerCase()).toBe('a'); + expect(viewCurrentLink).toHaveTextContent('View'); + }); + }); + + it('renders a "View" button for each older version without a restore link', async () => { + renderApp(); + + await waitFor(() => { + const viewVersion2 = screen.getByTestId('view-version-2'); + expect(viewVersion2.tagName.toLowerCase()).toBe('button'); + + const viewVersion1 = screen.getByTestId('view-version-1'); + expect(viewVersion1.tagName.toLowerCase()).toBe('button'); + }); + }); + + it('does not render any "Restore version" links', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.queryByText('Restore version')).not.toBeInTheDocument(); + }); + }); + }); + + describe('error handling', () => { + it('navigates to server error when the version history API call fails', async () => { + mockGetDocumentVersionHistoryResponse.mockRejectedValue(new Error('API error')); + + renderApp(); + + 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(); + renderApp(); + + 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, + expect.objectContaining({ + state: expect.objectContaining({ + isActiveVersion: true, + }), + }), + ); + }); + }); + + it('navigates to version history view when clicking View on an older version', async () => { + const user = userEvent.setup(); + renderApp(); + + 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, + expect.objectContaining({ + state: expect.objectContaining({ + isActiveVersion: false, + }), + }), + ); + }); + }); + + it('navigates to session expired when document loading returns 403', async () => { + const user = userEvent.setup(); + mockGetDocument.mockRejectedValue({ + response: { + status: 403, + }, + }); + + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-2')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-2')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); + }); + }); + + it('continues to version history view when document loading returns 404', async () => { + const user = userEvent.setup(); + mockGetDocument.mockRejectedValue({ + response: { + status: 404, + }, + }); + + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-2')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-2')); + + await waitFor(() => { + expect(mockGetObjectUrl).not.toHaveBeenCalled(); + expect(mockSetDocumentReference).toHaveBeenCalledWith(undefined); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + routeChildren.DOCUMENT_VIEW_VERSION_HISTORY, + expect.objectContaining({ + state: expect.objectContaining({ + isActiveVersion: false, + }), + }), + ); + }); + + it('navigates to server error with params when document loading returns 500', async () => { + const user = userEvent.setup(); + mockGetDocument.mockRejectedValue({ + response: { + status: 500, + data: { + err_code: 'ERR500', + interaction_id: 'interaction-123', + }, + }, + }); + + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-2')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-2')); + + await waitFor(() => { + expect( + mockNavigate.mock.calls.some( + ([path]) => + typeof path === 'string' && /^\/server-error\?encodedError=/.test(path), + ), + ).toBe(true); + }); + }); + + it('navigates to server error when getDocumentReferenceFromFhir throws', async () => { + const user = userEvent.setup(); + vi.spyOn(fhirUtil, 'getDocumentReferenceFromFhir').mockImplementation(() => { + throw new Error('invalid fhir reference'); + }); + + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-2')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-2')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR); + }); + }); + + it('navigates to session expired when handleViewVersion catches a 403 error', async () => { + const user = userEvent.setup(); + vi.spyOn(fhirUtil, 'getDocumentReferenceFromFhir').mockImplementation(() => { + throw { + response: { + status: 403, + }, + }; + }); + + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('view-version-2')).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('view-version-2')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); + }); + }); + }); +}); diff --git a/app/src/components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage.tsx b/app/src/components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage.tsx new file mode 100644 index 000000000..e22115c46 --- /dev/null +++ b/app/src/components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage.tsx @@ -0,0 +1,310 @@ +import { AxiosError } from 'axios'; +import { Button } from 'nhsuk-react-components'; +import { useEffect, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import useBaseAPIHeaders from '../../../../helpers/hooks/useBaseAPIHeaders'; +import useBaseAPIUrl from '../../../../helpers/hooks/useBaseAPIUrl'; +import usePatient from '../../../../helpers/hooks/usePatient'; +import useTitle from '../../../../helpers/hooks/useTitle'; +import getDocument, { GetDocumentResponse } from '../../../../helpers/requests/getDocument'; +import getDocumentSearchResults from '../../../../helpers/requests/getDocumentSearchResults'; +import { getDocumentVersionHistoryResponse } from '../../../../helpers/requests/getDocumentVersionHistory'; +import { + DOCUMENT_TYPE, + getConfigForDocTypeGeneric, + getDocumentTypeLabel, + LGContentKeys, +} from '../../../../helpers/utils/documentType'; +import { errorToParams } from '../../../../helpers/utils/errorToParams'; +import { + getAuthorValue, + getCreatedDate, + getDocumentReferenceFromFhir, + getVersionId, +} from '../../../../helpers/utils/fhirUtil'; +import { getFormatDateWithAtTime } from '../../../../helpers/utils/formatDate'; +import { getObjectUrl } from '../../../../helpers/utils/getPdfObjectUrl'; +import { isLocal } from '../../../../helpers/utils/isLocal'; +import { Bundle } from '../../../../types/fhirR4/bundle'; +import { FhirDocumentReference } from '../../../../types/fhirR4/documentReference'; +import { routeChildren, routes } from '../../../../types/generic/routes'; +import { DocumentReference } from '../../../../types/pages/documentSearchResultsPage/types'; +import BackButton from '../../../generic/backButton/BackButton'; +import { CreatedByText } from '../../../generic/createdBy/createdBy'; +import Spinner from '../../../generic/spinner/Spinner'; +import Timeline, { TimelineStatus } from '../../../generic/timeline/Timeline'; + +type DocumentVersionRestoreHistoryPageProps = { + documentReference: DocumentReference | null; + setDocumentReferenceToRestore: (docRef: DocumentReference) => void; + setDocumentReference: (docRef: DocumentReference) => void; + setLatestVersion: (version: string) => void; +}; + +const DocumentVersionRestoreHistoryStage = ({ + documentReference, + setDocumentReferenceToRestore, + setDocumentReference, + setLatestVersion, +}: Readonly): React.JSX.Element => { + const navigate = useNavigate(); + const baseUrl = useBaseAPIUrl(); + const baseHeaders = useBaseAPIHeaders(); + 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 docConfig = documentReference + ? getConfigForDocTypeGeneric(documentReference.documentSnomedCodeType) + : null; + const pageHeader = + docConfig?.content.getValue('versionHistoryHeader') || + `Version history for ${docTypeLabel}`; + useTitle({ pageTitle: pageHeader }); + + useEffect(() => { + if (!versionHistoryRef.current) { + versionHistoryRef.current = true; + const fetchVersionHistory = async (): Promise => { + try { + const searchResults = await getDocumentSearchResults({ + nhsNumber, + baseUrl, + baseHeaders, + docType: + documentReference?.documentSnomedCodeType ?? DOCUMENT_TYPE.LLOYD_GEORGE, + limit: 1, + }); + + setDocumentReference(searchResults[0]); + const latestDocRefId = searchResults[0]?.id; + setLatestVersion(searchResults[0]?.version ?? ''); + + if (!latestDocRefId) { + setLoading(false); + navigate(routes.SERVER_ERROR); + return; + } + + const response = await getDocumentVersionHistoryResponse({ + nhsNumber, + baseUrl, + baseHeaders, + documentReferenceId: latestDocRefId, + }); + + setVersionHistory(response); + setLoading(false); + } 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); + } + }; + void fetchVersionHistory(); + } + }, []); + + const loadDocument = async ( + documentId: string, + version?: string, + baseRef?: DocumentReference, + ): Promise => { + try { + const documentResponse = await getDocument({ + nhsNumber: patientDetails!.nhsNumber, + baseUrl, + baseHeaders, + documentId, + version, + }); + + const docRef = await handleViewDocSuccess(documentResponse, baseRef); + return docRef; + } catch (e) { + if (isLocal) { + const docRef = await handleViewDocSuccess( + { + url: '/dev/testFile.pdf', + contentType: 'application/pdf', + }, + baseRef, + ); + return docRef; + } + const error = e as AxiosError; + if (error.response?.status === 403) { + navigate(routes.SESSION_EXPIRED); + } else if (error.response?.status === 404) { + await handleViewDocSuccess({ url: '', contentType: '' }, baseRef); + } else { + navigate(routes.SERVER_ERROR + errorToParams(error)); + } + } + }; + + const handleViewDocSuccess = async ( + documentResponse: GetDocumentResponse, + baseRef?: DocumentReference, + ): Promise => { + const ref = baseRef ?? documentReference!; + return { + ...ref, + url: documentResponse.url ? await getObjectUrl(documentResponse.url) : null, + isPdf: documentResponse.contentType === 'application/pdf', + }; + }; + + const handleViewVersion = async ( + e: React.MouseEvent, + doc: FhirDocumentReference, + isActiveVersion?: boolean, + ): Promise => { + e.preventDefault(); + setLoading(true); + try { + const documentRef = getDocumentReferenceFromFhir(doc); + let documentRefId = documentRef.id; + if (documentRef.id.includes('~')) { + documentRefId = documentRef.id.split('~')[1]; + } + + const docRef = await loadDocument(documentRefId, documentRef.version, documentRef); + + setDocumentReferenceToRestore(docRef!); + + navigate(routeChildren.DOCUMENT_VIEW_VERSION_HISTORY, { + state: { isActiveVersion }, + }); + } 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); + } + }; + + if (loading) { + return ; + } + const renderVersionHistoryTimeline = (): React.JSX.Element => { + if (!versionHistory?.entry || versionHistory.entry.length === 0) { + return

No version history available for this document.

; + } + + const sortedEntries = [...versionHistory.entry].sort( + (a, b) => Number(getVersionId(b.resource)) - Number(getVersionId(a.resource)), + ); + + return ( + + {sortedEntries.map((entry, index) => { + 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 === sortedEntries.length - 1; + const doc = entry.resource; + const version = getVersionId(doc); + const heading = + docConfig?.content.getValueFormatString( + 'versionHistoryTimelineHeader', + { version }, + ) || `${docTypeLabel}: version ${version}`; + + return ( + + + {heading} + + + {isActiveVersion && ( + + This is the current version shown in this patient's record + + )} + + + + {isActiveVersion ? ( + , + ): Promise => handleViewVersion(e, doc, true)} + > + View + + ) : ( +
+ +
+ )} +
+ ); + })} +
+ ); + }; + + return ( +
+ + +

{pageHeader}

+ + {renderVersionHistoryTimeline()} +
+ ); +}; + +export default DocumentVersionRestoreHistoryStage; diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx index 9ab6268a1..7acdf7eae 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 => { @@ -515,4 +528,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..0219dc9ce 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx @@ -1,13 +1,18 @@ -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, 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,27 +22,31 @@ 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; + removeDocument?: () => void; + viewState?: DOCUMENT_VIEW_STATE; + isActiveVersion?: boolean; }; const DocumentView = ({ documentReference, removeDocument, + viewState, + isActiveVersion, }: Readonly): React.JSX.Element => { const [session, setUserSession] = useSessionContext(); const role = useRole(); @@ -52,6 +61,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 +87,7 @@ const DocumentView = ({ return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); }; - }, [session, setUserSession]); + }, [session, setUserSession, documentReference, getPdfObjectUrl]); if (!documentReference) { navigate(routes.PATIENT_DOCUMENTS); @@ -118,11 +135,13 @@ const DocumentView = ({ const removeClicked = (): void => { disableFullscreen(); - removeDocument(); + if (removeDocument) { + removeDocument(); + } }; const getLinks = (): Array => { - if (session.isFullscreen) { + if (session.isFullscreen || viewState === DOCUMENT_VIEW_STATE.VERSION_HISTORY) { return []; } @@ -182,14 +201,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 +270,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 +394,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/getDocument.test.ts b/app/src/helpers/requests/getDocument.test.ts index b33fe2d0c..6f278967d 100644 --- a/app/src/helpers/requests/getDocument.test.ts +++ b/app/src/helpers/requests/getDocument.test.ts @@ -46,6 +46,26 @@ describe('getDocument', () => { expect(result).toEqual(mockResponse); }); + it('should use the version history route when version is provided', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockResponse }); + + const result = await getDocument({ + ...mockArgs, + version: '7', + }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + `${mockArgs.baseUrl}${endpoints.DOCUMENT_REFERENCE}/${mockArgs.documentId}/_history/7`, + { + headers: mockArgs.baseHeaders, + params: { + patientId: mockArgs.nhsNumber, + }, + }, + ); + expect(result).toEqual(mockResponse); + }); + it('should throw AxiosError when request fails', async () => { const mockError = new Error('Network Error'); mockedAxios.get.mockRejectedValueOnce(mockError); diff --git a/app/src/helpers/requests/getDocument.ts b/app/src/helpers/requests/getDocument.ts index 71e3b6d01..bd4a97a98 100644 --- a/app/src/helpers/requests/getDocument.ts +++ b/app/src/helpers/requests/getDocument.ts @@ -8,6 +8,7 @@ export type GetDocumentArgs = { baseUrl: string; baseHeaders: AuthHeaders; documentId: string; + version?: string; }; export type GetDocumentResponse = { @@ -20,6 +21,7 @@ const getDocument = async ({ baseUrl, baseHeaders, documentId, + version, }: GetDocumentArgs): Promise => { if (isLocal) { return { @@ -28,7 +30,9 @@ const getDocument = async ({ }; } - const gatewayUrl = baseUrl + endpoints.DOCUMENT_REFERENCE + `/${documentId}`; + const gatewayUrl = version + ? `${baseUrl}${endpoints.DOCUMENT_REFERENCE}/${documentId}/_history/${version}` + : `${baseUrl}${endpoints.DOCUMENT_REFERENCE}/${documentId}`; try { const { data } = await axios.get(gatewayUrl, { diff --git a/app/src/helpers/requests/getDocumentSearchResults.ts b/app/src/helpers/requests/getDocumentSearchResults.ts index fd29758ea..fc93a4417 100644 --- a/app/src/helpers/requests/getDocumentSearchResults.ts +++ b/app/src/helpers/requests/getDocumentSearchResults.ts @@ -11,6 +11,7 @@ export type DocumentSearchResultsArgs = { baseUrl: string; baseHeaders: AuthHeaders; docType?: DOCUMENT_TYPE; + limit?: number; }; export type GetDocumentSearchResultsResponse = { @@ -22,6 +23,7 @@ const getDocumentSearchResults = async ({ baseUrl, baseHeaders, docType, + limit, }: DocumentSearchResultsArgs): Promise> => { const gatewayUrl = baseUrl + endpoints.DOCUMENT_SEARCH; @@ -33,7 +35,7 @@ const getDocumentSearchResults = async ({ params: { patientId: nhsNumber?.replaceAll(/\s/g, ''), // replace whitespace docType: docType === DOCUMENT_TYPE.ALL ? undefined : docType, - limit: 9999, + limit: limit ?? 9999, }, }); return data.references; 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.test.ts b/app/src/helpers/utils/getPdfObjectUrl.test.ts index 0ceadd0eb..c74549a4f 100644 --- a/app/src/helpers/utils/getPdfObjectUrl.test.ts +++ b/app/src/helpers/utils/getPdfObjectUrl.test.ts @@ -1,288 +1,342 @@ import { describe, expect, it, vi, Mock, Mocked, beforeEach, afterEach } from 'vitest'; import axios from 'axios'; -import { getPdfObjectUrl } from './getPdfObjectUrl'; +import { getPdfObjectUrl, getObjectUrl } from './getPdfObjectUrl'; import { DOWNLOAD_STAGE } from '../../types/generic/downloadStage'; vi.mock('axios'); const mockedAxios = axios as Mocked; -describe('getPdfObjectUrl', () => { - const mockSetPdfObjectUrl = vi.fn(); - const mockSetDownloadStage = vi.fn(); - const testCloudFrontUrl = 'https://cloudfront.example.com/test-file.pdf'; +describe('getPdfObjectUrl.ts', () => { + describe('getPdfObjectUrl', () => { + const mockSetPdfObjectUrl = vi.fn(); + const mockSetDownloadStage = vi.fn(); + const testCloudFrontUrl = 'https://cloudfront.example.com/test-file.pdf'; - // Mock URL.createObjectURL - const mockObjectUrl = 'blob:http://localhost:3000/test-blob-id'; - const originalCreateObjectURL = URL.createObjectURL; + // Mock URL.createObjectURL + const mockObjectUrl = 'blob:http://localhost:3000/test-blob-id'; + const originalCreateObjectURL = URL.createObjectURL; - beforeEach(() => { - vi.clearAllMocks(); - URL.createObjectURL = vi.fn((): string => mockObjectUrl); - }); + beforeEach(() => { + vi.clearAllMocks(); + URL.createObjectURL = vi.fn((): string => mockObjectUrl); + }); - afterEach(() => { - URL.createObjectURL = originalCreateObjectURL; - }); + afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + }); - describe('Successful PDF download', () => { - it('fetches PDF from cloudFrontUrl with correct config', async () => { - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + describe('Successful PDF download', () => { + it('fetches PDF from cloudFrontUrl with correct config', async () => { + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - expect(mockedAxios.get).toHaveBeenCalledTimes(1); - expect(mockedAxios.get).toHaveBeenCalledWith(testCloudFrontUrl, { - responseType: 'blob', + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(mockedAxios.get).toHaveBeenCalledWith(testCloudFrontUrl, { + responseType: 'blob', + }); }); - }); - - it('creates blob URL from response data', async () => { - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + it('creates blob URL from response data', async () => { + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); - }); - - it('calls setPdfObjectUrl with created object URL', async () => { - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + }); - expect(mockSetPdfObjectUrl).toHaveBeenCalledTimes(1); - expect(mockSetPdfObjectUrl).toHaveBeenCalledWith(mockObjectUrl); - }); + it('calls setPdfObjectUrl with created object URL', async () => { + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - it('sets download stage to SUCCEEDED', async () => { - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + expect(mockSetPdfObjectUrl).toHaveBeenCalledTimes(1); + expect(mockSetPdfObjectUrl).toHaveBeenCalledWith(mockObjectUrl); + }); - expect(mockSetDownloadStage).toHaveBeenCalledTimes(1); - expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); - }); + it('sets download stage to SUCCEEDED', async () => { + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - it('completes successfully with all callbacks', async () => { - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - await expect( - getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), - ).resolves.not.toThrow(); + expect(mockSetDownloadStage).toHaveBeenCalledTimes(1); + expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); + }); - expect(mockSetPdfObjectUrl).toHaveBeenCalled(); - expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); - }); - }); + it('completes successfully with all callbacks', async () => { + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - describe('Error handling', () => { - it('throws error when axios request fails', async () => { - const mockError = new Error('Network error'); - mockedAxios.get.mockRejectedValue(mockError); + await expect( + getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), + ).resolves.not.toThrow(); - await expect( - getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), - ).rejects.toThrow('Network error'); + expect(mockSetPdfObjectUrl).toHaveBeenCalled(); + expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); + }); }); - it('does not call setPdfObjectUrl or setDownloadStage on error', async () => { - const mockError = new Error('Network error'); - mockedAxios.get.mockRejectedValue(mockError); + describe('Error handling', () => { + it('throws error when axios request fails', async () => { + const mockError = new Error('Network error'); + mockedAxios.get.mockRejectedValue(mockError); - try { - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - } catch (error) { - // Expected error - } + await expect( + getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), + ).rejects.toThrow('Network error'); + }); - expect(mockSetPdfObjectUrl).not.toHaveBeenCalled(); - expect(mockSetDownloadStage).not.toHaveBeenCalled(); - }); + it('does not call setPdfObjectUrl or setDownloadStage on error', async () => { + const mockError = new Error('Network error'); + mockedAxios.get.mockRejectedValue(mockError); + + try { + await getPdfObjectUrl( + testCloudFrontUrl, + mockSetPdfObjectUrl, + mockSetDownloadStage, + ); + } catch (error) { + // Expected error + } + + expect(mockSetPdfObjectUrl).not.toHaveBeenCalled(); + expect(mockSetDownloadStage).not.toHaveBeenCalled(); + }); - it('handles 403 forbidden error', async () => { - const mockError = { - response: { - status: 403, - statusText: 'Forbidden', - }, - message: 'Request failed with status code 403', - }; - mockedAxios.get.mockRejectedValue(mockError); - - await expect( - getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), - ).rejects.toMatchObject({ - message: 'Request failed with status code 403', + it('handles 403 forbidden error', async () => { + const mockError = { + response: { + status: 403, + statusText: 'Forbidden', + }, + message: 'Request failed with status code 403', + }; + mockedAxios.get.mockRejectedValue(mockError); + + await expect( + getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), + ).rejects.toMatchObject({ + message: 'Request failed with status code 403', + }); }); - }); - it('handles 404 not found error', async () => { - const mockError = { - response: { - status: 404, - statusText: 'Not Found', - }, - message: 'Request failed with status code 404', - }; - mockedAxios.get.mockRejectedValue(mockError); - - await expect( - getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), - ).rejects.toMatchObject({ - message: 'Request failed with status code 404', + it('handles 404 not found error', async () => { + const mockError = { + response: { + status: 404, + statusText: 'Not Found', + }, + message: 'Request failed with status code 404', + }; + mockedAxios.get.mockRejectedValue(mockError); + + await expect( + getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), + ).rejects.toMatchObject({ + message: 'Request failed with status code 404', + }); }); - }); - it('handles 500 server error', async () => { - const mockError = { - response: { - status: 500, - statusText: 'Internal Server Error', - }, - message: 'Request failed with status code 500', - }; - mockedAxios.get.mockRejectedValue(mockError); - - await expect( - getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), - ).rejects.toMatchObject({ - message: 'Request failed with status code 500', + it('handles 500 server error', async () => { + const mockError = { + response: { + status: 500, + statusText: 'Internal Server Error', + }, + message: 'Request failed with status code 500', + }; + mockedAxios.get.mockRejectedValue(mockError); + + await expect( + getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), + ).rejects.toMatchObject({ + message: 'Request failed with status code 500', + }); }); - }); - it('handles timeout error', async () => { - const mockError = { - code: 'ECONNABORTED', - message: 'timeout of 30000ms exceeded', - }; - mockedAxios.get.mockRejectedValue(mockError); - - await expect( - getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), - ).rejects.toMatchObject({ - message: 'timeout of 30000ms exceeded', + it('handles timeout error', async () => { + const mockError = { + code: 'ECONNABORTED', + message: 'timeout of 30000ms exceeded', + }; + mockedAxios.get.mockRejectedValue(mockError); + + await expect( + getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage), + ).rejects.toMatchObject({ + message: 'timeout of 30000ms exceeded', + }); }); }); - }); - describe('Different blob types', () => { - it('handles large PDF blobs', async () => { - // Create a larger blob to simulate real PDFs - const largeData = new Uint8Array(1024 * 1024); // 1MB - const mockBlob = new Blob([largeData], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + describe('Different blob types', () => { + it('handles large PDF blobs', async () => { + // Create a larger blob to simulate real PDFs + const largeData = new Uint8Array(1024 * 1024); // 1MB + const mockBlob = new Blob([largeData], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); - expect(mockSetPdfObjectUrl).toHaveBeenCalledWith(mockObjectUrl); - }); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(mockSetPdfObjectUrl).toHaveBeenCalledWith(mockObjectUrl); + }); - it('handles empty blob', async () => { - const mockBlob = new Blob([], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + it('handles empty blob', async () => { + const mockBlob = new Blob([], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); - expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); - }); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); + }); - it('handles blob without explicit type', async () => { - const mockBlob = new Blob(['test data']); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + it('handles blob without explicit type', async () => { + const mockBlob = new Blob(['test data']); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + await getPdfObjectUrl(testCloudFrontUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); - expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(mockSetDownloadStage).toHaveBeenCalledWith(DOWNLOAD_STAGE.SUCCEEDED); + }); }); - }); - describe('Different URLs', () => { - it('handles different CloudFront URLs', async () => { - const differentUrl = 'https://cdn.example.com/documents/patient123.pdf'; - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + describe('Different URLs', () => { + it('handles different CloudFront URLs', async () => { + const differentUrl = 'https://cdn.example.com/documents/patient123.pdf'; + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(differentUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + await getPdfObjectUrl(differentUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - expect(mockedAxios.get).toHaveBeenCalledWith(differentUrl, { - responseType: 'blob', + expect(mockedAxios.get).toHaveBeenCalledWith(differentUrl, { + responseType: 'blob', + }); }); - }); - it('handles URLs with query parameters', async () => { - //TODO Review tests - const urlWithParams = 'https://cloudfront.example.com/file.pdf?token=abc&expires=123'; - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + it('handles URLs with query parameters', async () => { + //TODO Review tests + const urlWithParams = + 'https://cloudfront.example.com/file.pdf?token=abc&expires=123'; + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(urlWithParams, mockSetPdfObjectUrl, mockSetDownloadStage); + await getPdfObjectUrl(urlWithParams, mockSetPdfObjectUrl, mockSetDownloadStage); - expect(mockedAxios.get).toHaveBeenCalledWith(urlWithParams, { - responseType: 'blob', + expect(mockedAxios.get).toHaveBeenCalledWith(urlWithParams, { + responseType: 'blob', + }); }); - }); - it('handles URLs with special characters', async () => { - const specialUrl = 'https://cloudfront.example.com/files/patient%20record%20(1).pdf'; - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); + it('handles URLs with special characters', async () => { + const specialUrl = + 'https://cloudfront.example.com/files/patient%20record%20(1).pdf'; + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); - await getPdfObjectUrl(specialUrl, mockSetPdfObjectUrl, mockSetDownloadStage); + await getPdfObjectUrl(specialUrl, mockSetPdfObjectUrl, mockSetDownloadStage); - expect(mockedAxios.get).toHaveBeenCalledWith(specialUrl, { - responseType: 'blob', + expect(mockedAxios.get).toHaveBeenCalledWith(specialUrl, { + responseType: 'blob', + }); }); }); - }); - - describe('Callback execution order', () => { - it('calls setPdfObjectUrl before setDownloadStage', async () => { - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); - mockedAxios.get.mockResolvedValue({ data: mockBlob }); - const callOrder: string[] = []; - const trackingSetPdfObjectUrl = vi.fn((): void => { - callOrder.push('setPdfObjectUrl'); + describe('Callback execution order', () => { + it('calls setPdfObjectUrl before setDownloadStage', async () => { + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); + + const callOrder: string[] = []; + const trackingSetPdfObjectUrl = vi.fn((): void => { + callOrder.push('setPdfObjectUrl'); + }); + const trackingSetDownloadStage = vi.fn((): void => { + callOrder.push('setDownloadStage'); + }); + + await getPdfObjectUrl( + testCloudFrontUrl, + trackingSetPdfObjectUrl, + trackingSetDownloadStage, + ); + + expect(callOrder).toEqual(['setPdfObjectUrl', 'setDownloadStage']); }); - const trackingSetDownloadStage = vi.fn((): void => { - callOrder.push('setDownloadStage'); + + it('ensures blob URL is created before calling setPdfObjectUrl', async () => { + const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); + + const callOrder: string[] = []; + (URL.createObjectURL as Mock).mockImplementation((): string => { + callOrder.push('createObjectURL'); + return mockObjectUrl; + }); + const trackingSetPdfObjectUrl = vi.fn((): void => { + callOrder.push('setPdfObjectUrl'); + }); + + await getPdfObjectUrl( + testCloudFrontUrl, + trackingSetPdfObjectUrl, + mockSetDownloadStage, + ); + + expect(callOrder[0]).toBe('createObjectURL'); + expect(callOrder[1]).toBe('setPdfObjectUrl'); }); + }); + }); + + describe('getObjectUrl', () => { + const testCloudFrontUrl = 'https://cloudfront.example.com/test-file.pdf'; + const mockObjectUrl = 'blob:http://localhost:3000/test-blob-id'; + const originalCreateObjectURL = URL.createObjectURL; - await getPdfObjectUrl( - testCloudFrontUrl, - trackingSetPdfObjectUrl, - trackingSetDownloadStage, - ); + beforeEach(() => { + vi.clearAllMocks(); + URL.createObjectURL = vi.fn((): string => mockObjectUrl); + }); - expect(callOrder).toEqual(['setPdfObjectUrl', 'setDownloadStage']); + afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; }); - it('ensures blob URL is created before calling setPdfObjectUrl', async () => { - const mockBlob = new Blob(['test pdf data'], { type: 'application/pdf' }); + it('fetches blob from the provided URL', async () => { + const mockBlob = new Blob(['test data'], { type: 'application/pdf' }); mockedAxios.get.mockResolvedValue({ data: mockBlob }); - const callOrder: string[] = []; - (URL.createObjectURL as Mock).mockImplementation((): string => { - callOrder.push('createObjectURL'); - return mockObjectUrl; - }); - const trackingSetPdfObjectUrl = vi.fn((): void => { - callOrder.push('setPdfObjectUrl'); + await getObjectUrl(testCloudFrontUrl); + + expect(mockedAxios.get).toHaveBeenCalledWith(testCloudFrontUrl, { + responseType: 'blob', }); + }); + + it('returns a blob object URL', async () => { + const mockBlob = new Blob(['test data'], { type: 'application/pdf' }); + mockedAxios.get.mockResolvedValue({ data: mockBlob }); + + const result = await getObjectUrl(testCloudFrontUrl); + + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(result).toBe(mockObjectUrl); + }); - await getPdfObjectUrl(testCloudFrontUrl, trackingSetPdfObjectUrl, mockSetDownloadStage); + it('throws when the fetch fails', async () => { + mockedAxios.get.mockRejectedValue(new Error('Network error')); - expect(callOrder[0]).toBe('createObjectURL'); - expect(callOrder[1]).toBe('setPdfObjectUrl'); + await expect(getObjectUrl(testCloudFrontUrl)).rejects.toThrow('Network error'); }); }); }); diff --git a/app/src/helpers/utils/getPdfObjectUrl.ts b/app/src/helpers/utils/getPdfObjectUrl.ts index f270a6e18..90ac7663b 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,16 @@ 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; +}; + +export const getObjectUrl = async (cloudFrontUrl: string): Promise => { + const data = await fetchBlob(cloudFrontUrl); + + return URL.createObjectURL(data); +}; diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx index 6b368e601..339e5e792 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'; @@ -34,7 +36,6 @@ import ProgressBar from '../../components/generic/progressBar/ProgressBar'; import DeleteSubmitStage from '../../components/blocks/_delete/deleteSubmitStage/DeleteSubmitStage'; import { Button, WarningCallout } from 'nhsuk-react-components'; import getReviews from '../../helpers/requests/getReviews'; -import DocumentVersionHistoryPage from '../documentVersionHistoryPage/DocumentVersionHistoryPage'; const DocumentSearchResultsPage = (): React.JSX.Element => { const patientDetails = usePatient(); @@ -194,16 +195,11 @@ const DocumentSearchResultsPage = (): React.JSX.Element => { /> } /> - - } - /> diff --git a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx deleted file mode 100644 index fe0e33ec7..000000000 --- a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { render, RenderResult, screen, waitFor } from '@testing-library/react'; -import { JSX } from 'react/jsx-runtime'; -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import useBaseAPIHeaders from '../../helpers/hooks/useBaseAPIHeaders'; -import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; -import usePatient from '../../helpers/hooks/usePatient'; -import { getDocumentVersionHistoryResponse } from '../../helpers/requests/getDocumentVersionHistory'; -import { mockDocumentVersionHistoryResponse } from '../../helpers/test/getMockVersionHistory'; -import { buildPatientDetails, buildSearchResult } from '../../helpers/test/testBuilders'; -import { DOCUMENT_TYPE } from '../../helpers/utils/documentType'; -import DocumentVersionHistoryPage from './DocumentVersionHistoryPage'; - -const mockNavigate = vi.fn(); -const mockUseLocation = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: (): Mock => mockNavigate, - useLocation: (): unknown => mockUseLocation(), - Link: ({ children, to, ...props }: any): JSX.Element => ( - - {children} - - ), - }; -}); - -vi.mock('../../helpers/hooks/usePatient'); -vi.mock('../../helpers/hooks/useBaseAPIUrl'); -vi.mock('../../helpers/hooks/useBaseAPIHeaders'); -vi.mock('../../helpers/requests/getDocumentVersionHistory'); -vi.mock('../../helpers/hooks/useTitle'); - -const mockedUsePatient = usePatient as Mock; -const mockUseBaseAPIUrl = useBaseAPIUrl as Mock; -const mockUseBaseAPIHeaders = useBaseAPIHeaders as Mock; -const mockGetDocumentVersionHistoryResponse = getDocumentVersionHistoryResponse as Mock; - -const mockPatientDetails = buildPatientDetails(); -const mockDocumentReference = buildSearchResult({ - documentSnomedCodeType: DOCUMENT_TYPE.LLOYD_GEORGE, - id: 'doc-ref-123', -}); - -const renderPage = (): RenderResult => - render(); - -describe('DocumentVersionHistoryPage', () => { - beforeEach(() => { - import.meta.env.VITE_ENVIRONMENT = 'vitest'; - mockedUsePatient.mockReturnValue(mockPatientDetails); - mockUseBaseAPIUrl.mockReturnValue('http://localhost'); - mockUseBaseAPIHeaders.mockReturnValue({ Authorization: 'Bearer token' }); - mockUseLocation.mockReturnValue({ - state: { documentReference: mockDocumentReference }, - }); - mockGetDocumentVersionHistoryResponse.mockResolvedValue(mockDocumentVersionHistoryResponse); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('loading state', () => { - it('renders a spinner while the version history is loading', () => { - mockGetDocumentVersionHistoryResponse.mockReturnValue(new Promise(() => {})); - - renderPage(); - - expect(screen.getByText('Loading version history')).toBeInTheDocument(); - }); - }); - - describe('page structure', () => { - it('renders the back button', async () => { - renderPage(); - - await waitFor(() => { - expect(screen.getByTestId('go-back-button')).toBeInTheDocument(); - }); - }); - - it('renders the page heading with the correct document type label', async () => { - renderPage(); - - await waitFor(() => { - expect( - screen.getByRole('heading', { - name: /version history for scanned paper notes/i, - }), - ).toBeInTheDocument(); - }); - }); - }); - - describe('version history timeline', () => { - it('renders "no version history" message when the bundle has an empty entries array', async () => { - mockGetDocumentVersionHistoryResponse.mockResolvedValue({ - resourceType: 'Bundle', - type: 'history', - total: 0, - entry: [], - }); - - renderPage(); - - await waitFor(() => { - expect( - screen.getByText('No version history available for this document.'), - ).toBeInTheDocument(); - }); - }); - - it('renders "no version history" message when the bundle has no entry property', async () => { - mockGetDocumentVersionHistoryResponse.mockResolvedValue({ - resourceType: 'Bundle', - type: 'history', - total: 0, - }); - - renderPage(); - - await waitFor(() => { - expect( - screen.getByText('No version history available for this document.'), - ).toBeInTheDocument(); - }); - }); - - it('renders all version history entries with correct headings', async () => { - renderPage(); - - await waitFor(() => { - expect(screen.getByText('Scanned paper notes: version 3')).toBeInTheDocument(); - expect(screen.getByText('Scanned paper notes: version 2')).toBeInTheDocument(); - expect(screen.getByText('Scanned paper notes: version 1')).toBeInTheDocument(); - }); - }); - - it('shows "this is the current version" only for the first (most recent) entry', async () => { - renderPage(); - - await waitFor(() => { - const currentVersionMessages = screen.getAllByText( - "This is the current version shown in this patient's record", - ); - expect(currentVersionMessages).toHaveLength(1); - }); - }); - - it('renders a "View" link (not a button) for the current version', async () => { - renderPage(); - - await waitFor(() => { - const viewCurrentLink = screen.getByTestId('view-version-3'); - expect(viewCurrentLink.tagName.toLowerCase()).toBe('a'); - expect(viewCurrentLink).toHaveTextContent('View'); - }); - }); - - it('renders a "View" button and a "Restore version" link for each older version', async () => { - renderPage(); - - await waitFor(() => { - const viewVersion2 = screen.getByTestId('view-version-2'); - expect(viewVersion2.tagName.toLowerCase()).toBe('button'); - - const restoreVersion2 = screen.getByTestId('restore-version-2'); - expect(restoreVersion2).toHaveTextContent('Restore version'); - - const viewVersion1 = screen.getByTestId('view-version-1'); - expect(viewVersion1.tagName.toLowerCase()).toBe('button'); - - const restoreVersion1 = screen.getByTestId('restore-version-1'); - expect(restoreVersion1).toHaveTextContent('Restore version'); - }); - }); - - it('does not render a "Restore version" link for the current version', async () => { - renderPage(); - - await waitFor(() => { - expect(screen.queryByTestId('restore-version-3')).not.toBeInTheDocument(); - }); - }); - }); -}); diff --git a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx deleted file mode 100644 index 3b20998d8..000000000 --- a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { Button } from 'nhsuk-react-components'; -import { useEffect, useRef, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import BackButton from '../../components/generic/backButton/BackButton'; -import { CreatedByText } from '../../components/generic/createdBy/createdBy'; -import Spinner from '../../components/generic/spinner/Spinner'; -import Timeline, { TimelineStatus } from '../../components/generic/timeline/Timeline'; -import useBaseAPIHeaders from '../../helpers/hooks/useBaseAPIHeaders'; -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 { getFormatDateWithAtTime } from '../../helpers/utils/formatDate'; -import { Bundle } from '../../types/fhirR4/bundle'; -import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; -import { routes } from '../../types/generic/routes'; -import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; - -type DocumentVersionHistoryPageProps = { - documentReference: DocumentReference | null; -}; - -const DocumentVersionHistoryPage = ({ - documentReference, -}: DocumentVersionHistoryPageProps): React.JSX.Element => { - const navigate = useNavigate(); - const baseUrl = useBaseAPIUrl(); - const baseHeaders = useBaseAPIHeaders(); - const versionHistoryRef = useRef(false); - const patientDetails = usePatient(); - const nhsNumber = patientDetails?.nhsNumber ?? ''; - - const docTypeLabel = documentReference - ? getDocumentTypeLabel(documentReference.documentSnomedCodeType) - : ''; - const pageHeader = `Version history for ${docTypeLabel.toLowerCase()}`; - 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; - try { - const response = await getDocumentVersionHistoryResponse({ - nhsNumber, - baseUrl, - baseHeaders, - documentReferenceId: documentReference.id, - }); - setVersionHistory(response); - } catch { - navigate(routes.PATIENT_DOCUMENTS); - } finally { - setLoading(false); - } - } - }; - void fetchVersionHistory(); - }, [documentReference, nhsNumber, baseUrl, baseHeaders, navigate]); - - if (loading) { - return ; - } - - if (!documentReference) { - navigate(routes.PATIENT_DOCUMENTS); - return <>; - } - - const renderVersionHistoryTimeline = (): React.JSX.Element => { - if (!versionHistory?.entry || versionHistory.entry.length === 0) { - return

No version history available for this document.

; - } - - const sortedEntries = [...versionHistory.entry].sort( - (a, b) => Number(getVersionId(b.resource)) - Number(getVersionId(a.resource)), - ); - - return ( - - {sortedEntries.map((entry, index) => { - const isCurrentVersion = index === 0; - const status = isCurrentVersion - ? TimelineStatus.Active - : TimelineStatus.Inactive; - const isLastItem = index === versionHistory.entry!.length - 1; - const doc = entry.resource; - const version = getVersionId(doc); - const heading = `${docTypeLabel}: version ${version}`; - - return ( - - - {heading} - - - {isCurrentVersion && ( - - This is the current version shown in this patient's record - - )} - - - - {isCurrentVersion ? ( - - View - - ) : ( -
- - - Restore version - -
- )} -
- ); - })} -
- ); - }; - - return ( -
- - -

{pageHeader}

- - {renderVersionHistoryTimeline()} -
- ); -}; - -export default DocumentVersionHistoryPage; diff --git a/app/src/pages/documentVersionPage/DocumentVersionRestorePage.test.tsx b/app/src/pages/documentVersionPage/DocumentVersionRestorePage.test.tsx new file mode 100644 index 000000000..057dfbc3d --- /dev/null +++ b/app/src/pages/documentVersionPage/DocumentVersionRestorePage.test.tsx @@ -0,0 +1,168 @@ +import { render, RenderResult, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { Mock } from 'vitest'; +import useConfig from '../../helpers/hooks/useConfig'; +import { routes } from '../../types/generic/routes'; +import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; +import DocumentVersionRestorePage from './DocumentVersionRestorePage'; + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: (): Mock => mockNavigate, + }; +}); + +vi.mock( + '../../components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage', + () => ({ + default: (props: { + documentReference: DocumentReference | null; + setDocumentReferenceToRestore: (docRef: DocumentReference) => void; + setDocumentReference: (docRef: DocumentReference) => void; + setLatestVersion: (version: string) => void; + }): React.JSX.Element => ( +
+ {props.documentReference && ( + {props.documentReference.id} + )} +
+ ), + }), +); + +vi.mock('../../components/blocks/_patientDocuments/documentView/DocumentView', () => ({ + default: (props: { + documentReference: DocumentReference | null; + viewState?: string; + isActiveVersion?: boolean; + }): React.JSX.Element => ( +
+ {props.viewState && {props.viewState}} + {props.isActiveVersion !== undefined && ( + {String(props.isActiveVersion)} + )} + {props.documentReference && ( + {props.documentReference.id} + )} +
+ ), + DOCUMENT_VIEW_STATE: { + DOCUMENT: 'DOCUMENT', + VERSION_HISTORY: 'VERSION_HISTORY', + }, +})); + +vi.mock('../../helpers/hooks/useConfig'); + +const mockedUseConfig = useConfig as Mock; + +describe('DocumentVersionRestorePage', () => { + beforeEach(() => { + import.meta.env.VITE_ENVIRONMENT = 'vitest'; + mockedUseConfig.mockReturnValue({ + featureFlags: { + versionHistoryEnabled: true, + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Feature flag guard', () => { + it('navigates to home when versionHistoryEnabled is false', async () => { + mockedUseConfig.mockReturnValue({ + featureFlags: { + versionHistoryEnabled: false, + }, + }); + + renderPage(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.HOME); + }); + }); + + it('renders empty fragment when versionHistoryEnabled is false', () => { + mockedUseConfig.mockReturnValue({ + featureFlags: { + versionHistoryEnabled: false, + }, + }); + + const { container } = renderPage(); + + expect(container.innerHTML).toBe(''); + }); + + it('does not navigate to home when versionHistoryEnabled is true', () => { + renderPage(); + + expect(mockNavigate).not.toHaveBeenCalledWith(routes.HOME); + }); + + it('navigates to home when featureFlags is undefined', async () => { + mockedUseConfig.mockReturnValue({ + featureFlags: undefined, + }); + + renderPage(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.HOME); + }); + }); + }); + + describe('Rendering', () => { + it('renders the history stage at the index route', () => { + renderPage(); + + expect(screen.getByTestId('history-stage')).toBeInTheDocument(); + }); + + it('renders the document view at the view route', () => { + renderPage('/patient/documents/version-history/view'); + + expect(screen.getByTestId('document-view')).toBeInTheDocument(); + }); + + it('passes VERSION_HISTORY view state to DocumentView', () => { + renderPage('/patient/documents/version-history/view'); + + expect(screen.getByTestId('view-state')).toHaveTextContent('VERSION_HISTORY'); + }); + }); + + describe('State management', () => { + it('passes null documentReference to history stage initially', () => { + renderPage(); + + expect(screen.queryByTestId('history-doc-ref')).not.toBeInTheDocument(); + }); + }); + + const renderPage = ( + initialPath: string = '/patient/documents/version-history', + ): RenderResult => { + const router = createMemoryRouter( + [ + { + path: '/patient/documents/version-history/*', + element: , + }, + ], + { + initialEntries: [initialPath], + }, + ); + + return render(); + }; +}); diff --git a/app/src/pages/documentVersionPage/DocumentVersionRestorePage.tsx b/app/src/pages/documentVersionPage/DocumentVersionRestorePage.tsx new file mode 100644 index 000000000..c4d7c7575 --- /dev/null +++ b/app/src/pages/documentVersionPage/DocumentVersionRestorePage.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { Route, Routes, useNavigate } from 'react-router-dom'; +import DocumentVersionRestoreHistoryStage from '../../components/blocks/_documentVersion/documentVersionRestoreHistoryStage/DocumentVersionRestoreHistoryStage'; +import DocumentView, { + DOCUMENT_VIEW_STATE, +} from '../../components/blocks/_patientDocuments/documentView/DocumentView'; +import useConfig from '../../helpers/hooks/useConfig'; +import { getLastURLPath } from '../../helpers/utils/urlManipulations'; +import { routeChildren, routes } from '../../types/generic/routes'; +import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; + +const DocumentVersionRestorePage = (): React.JSX.Element => { + const [documentReferenceToRestore, setDocumentReferenceToRestore] = + useState(null); + const [documentReference, setDocumentReference] = useState(null); + const [latestVersion, setLatestVersion] = useState(''); + const config = useConfig(); + const navigate = useNavigate(); + + useEffect(() => { + if (!config.featureFlags?.versionHistoryEnabled) { + navigate(routes.HOME); + } + }, [config.featureFlags, navigate]); + + if (!config.featureFlags?.versionHistoryEnabled) { + return <>; + } + + return ( + + + } + /> + + } + /> + + ); +}; + +export default DocumentVersionRestorePage; diff --git a/app/src/router/AppRouter.tsx b/app/src/router/AppRouter.tsx index 786d64c20..9c788d785 100644 --- a/app/src/router/AppRouter.tsx +++ b/app/src/router/AppRouter.tsx @@ -33,6 +33,7 @@ import CookiePolicyPage from '../pages/cookiePolicyPage/CookiePolicyPage'; import DocumentCorrectPage from '../pages/documentCorrectPage/DocumentCorrectPage'; import { AdminPage } from '../pages/adminPage/AdminPage'; import UserPatientRestrictionsPage from '../pages/userPatientRestrictionsPage/UserPatientRestrictionsPage'; +import DocumentVersionRestorePage from '../pages/documentVersionPage/DocumentVersionRestorePage'; const { START, @@ -69,6 +70,8 @@ const { COOKIES_POLICY_WILDCARD, DOCUMENT_REASSIGN_PAGES, DOCUMENT_REASSIGN_PAGES_WILDCARD, + DOCUMENT_VERSION_HISTORY, + DOCUMENT_VERSION_HISTORY_WILDCARD, USER_PATIENT_RESTRICTIONS, USER_PATIENT_RESTRICTIONS_WILDCARD, } = routes; @@ -427,6 +430,14 @@ export const routeMap: Routes = { page: , type: ROUTE_TYPE.PATIENT, }, + [DOCUMENT_VERSION_HISTORY]: { + page: , + type: ROUTE_TYPE.PATIENT, + }, + [DOCUMENT_VERSION_HISTORY_WILDCARD]: { + page: , + type: ROUTE_TYPE.PATIENT, + }, [USER_PATIENT_RESTRICTIONS]: { page: , type: ROUTE_TYPE.PRIVATE, 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 ───────────────────────────────────────────────────────── diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index f92f50c9b..26548f3da 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -30,6 +30,9 @@ export enum routes { DOCUMENT_REASSIGN_PAGES = '/patient/document-reassign-pages', DOCUMENT_REASSIGN_PAGES_WILDCARD = '/patient/document-reassign-pages/*', + DOCUMENT_VERSION_HISTORY = '/patient/documents/version-history', + DOCUMENT_VERSION_HISTORY_WILDCARD = '/patient/documents/version-history/*', + MOCK_LOGIN = 'Auth/MockLogin', ADMIN_ROUTE = '/admin', @@ -75,7 +78,7 @@ export enum routeChildren { DOCUMENT_REASSIGN_UPLOADING = '/patient/document-reassign-pages/uploading', DOCUMENT_REASSIGN_COMPLETE = '/patient/document-reassign-pages/complete', - DOCUMENT_VIEW_VERSION_HISTORY = '/patient/documents/version-history-view', + DOCUMENT_VIEW_VERSION_HISTORY = '/patient/documents/version-history/view', DOCUMENT_VERSION_HISTORY = '/patient/documents/version-history', DOCUMENT_VIEW = '/patient/documents/view',