diff --git a/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js b/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js index eeca32ea22..8b5d6ef998 100644 --- a/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js +++ b/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js @@ -11,16 +11,16 @@ describe('PCSE Workflow: Access and download found files', () => { fileName: 'Screenshot 2023-09-11 at 16.06.40.png', virusScannerResult: 'Not Scanned', created: new Date('2023-09-12T10:41:41.747836Z'), + documentSnomedCodeType: DOCUMENT_TYPE.LLOYD_GEORGE, }, { fileName: 'Screenshot 2023-09-08 at 14.53.47.png', virusScannerResult: 'Not Scanned', created: new Date('2023-09-12T10:41:41.749341Z'), + documentSnomedCodeType: DOCUMENT_TYPE.EHR, }, ]; - const homeUrl = '/'; - beforeEach(() => { cy.login(Roles.PCSE); cy.navigateToPatientSearchPage(); diff --git a/app/package-lock.json b/app/package-lock.json index 0fd2a5656e..525f60f942 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -23,6 +23,7 @@ "history": "^5.3.0", "jwt-decode": "^4.0.0", "moment": "^2.30.1", + "nhsapp-frontend": "^4.0.0", "nhsuk-frontend": "^9.6.4", "nhsuk-react-components": "^5.0.0", "pdf-lib": "^1.17.1", @@ -8996,6 +8997,15 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12935,22 +12945,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/listr2": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", @@ -13524,6 +13518,15 @@ "node": ">= 0.6" } }, + "node_modules/nhsapp-frontend": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nhsapp-frontend/-/nhsapp-frontend-4.0.0.tgz", + "integrity": "sha512-DGtH9DGgGOMxINvVsxo8uvZ2xfH9TckBaXHQnRWVYy2gSxQ+31Al51zhEJLSKVR8avZ8gyRFN8WhfCCEes+2qA==", + "license": "MIT", + "peerDependencies": { + "nhsuk-frontend": "^9.0.0" + } + }, "node_modules/nhsuk-frontend": { "version": "9.6.4", "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-9.6.4.tgz", @@ -16916,12 +16919,19 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/app/package.json b/app/package.json index 6f73800926..846023c84b 100644 --- a/app/package.json +++ b/app/package.json @@ -39,6 +39,7 @@ "history": "^5.3.0", "jwt-decode": "^4.0.0", "moment": "^2.30.1", + "nhsapp-frontend": "^4.0.0", "nhsuk-frontend": "^9.6.4", "nhsuk-react-components": "^5.0.0", "pdf-lib": "^1.17.1", diff --git a/app/src/components/blocks/_documentManagement/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx b/app/src/components/blocks/_documentManagement/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx index d70b9bbbe5..6ca09d263b 100644 --- a/app/src/components/blocks/_documentManagement/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx +++ b/app/src/components/blocks/_documentManagement/documentSelectOrderStage/DocumentSelectOrderStage.test.tsx @@ -11,7 +11,11 @@ import { MemoryRouter } from 'react-router-dom'; import { fileUploadErrorMessages } from '../../../../helpers/utils/fileUploadErrorMessages'; import { buildDocumentConfig, buildLgFile } from '../../../../helpers/test/testBuilders'; import { Mock } from 'vitest'; -import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType'; +import { + AllContentKeys, + DOCUMENT_TYPE, + DOCUMENT_TYPE_CONFIG_GENERIC, +} from '../../../../helpers/utils/documentType'; const mockNavigate = vi.fn(); const mockSetDocuments = vi.fn(); @@ -54,7 +58,7 @@ vi.mock('../documentUploadLloydGeorgePreview/DocumentUploadLloydGeorgePreview', }, })); -let docConfig: DOCUMENT_TYPE_CONFIG; +let docConfig: DOCUMENT_TYPE_CONFIG_GENERIC; describe('DocumentSelectOrderStage', () => { let documents: UploadDocument[] = []; diff --git a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx index 7525a001fd..a855ade5a2 100644 --- a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx +++ b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx @@ -3,7 +3,11 @@ import { SearchResult } from '../../../../types/generic/searchResult'; import { useSessionContext } from '../../../../providers/sessionProvider/SessionProvider'; import { REPOSITORY_ROLE } from '../../../../types/generic/authRole'; import { getFormattedDate } from '../../../../helpers/utils/formatDate'; -import { DOCUMENT_TYPE_CONFIG, getDocumentTypeLabel } from '../../../../helpers/utils/documentType'; +import { + DOCUMENT_TYPE_CONFIG, + getConfigForDocTypeGeneric, + getDocumentTypeLabel, +} from '../../../../helpers/utils/documentType'; import LinkButton from '../../../generic/linkButton/LinkButton'; type Props = { @@ -23,6 +27,19 @@ const DocumentSearchResults = ({ session.auth!.role === REPOSITORY_ROLE.GP_ADMIN || session.auth!.role === REPOSITORY_ROLE.GP_CLINICAL; + const documentTypeLabel = (doc: SearchResult): string => { + let docconfig = getConfigForDocTypeGeneric(doc.documentSnomedCodeType); + + const heading = docconfig.content.getValueFormatString( + 'searchResultDocumentTypeLabel', + { + version: doc.version, + }, + ); + + return heading ?? getDocumentTypeLabel(doc.documentSnomedCodeType) ?? 'Documents'; + }; + return (

@@ -54,7 +71,7 @@ const DocumentSearchResults = ({ id={`available-files-row-${index}-document-type`} data-testid="doctype" > - {getDocumentTypeLabel(result.documentSnomedCodeType) ?? 'Other'} + {documentTypeLabel(result)} { - const actual = await vi.importActual('../../../../helpers/utils/documentType'); + const actual = await vi.importActual( + '../../../../helpers/utils/documentType', + ); + realGetConfigForDocTypeGeneric = actual.getConfigForDocTypeGeneric; return { ...actual, - getConfigForDocType: vi.fn(), + getConfigForDocTypeGeneric: vi.fn(actual.getConfigForDocTypeGeneric), }; }); vi.mock('../../../../providers/analyticsProvider/AnalyticsProvider', () => ({ @@ -111,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 => { @@ -129,10 +151,12 @@ describe('DocumentView', () => { mockUseConfig.mockReturnValue({ featureFlags: { documentCorrectEnabled: false, + versionHistoryEnabled: false, }, }); mockUseRole.mockReturnValue(REPOSITORY_ROLE.GP_ADMIN); - vi.mocked(getConfigForDocType).mockReturnValue(buildDocumentConfig()); + + vi.mocked(getConfigForDocTypeGeneric).mockImplementation(realGetConfigForDocTypeGeneric); // Mock fullscreen API Object.defineProperty(document, 'fullscreenEnabled', { @@ -349,8 +373,12 @@ describe('DocumentView', () => { ])( 'displays add button when %s', async ({ canBeUpdated, role, deceased, fullscreen, addBtnVisible }) => { - vi.mocked(getConfigForDocType).mockReturnValue( - buildDocumentConfig({ canBeUpdated }), + vi.mocked(getConfigForDocTypeGeneric).mockImplementation( + (docType) => + ({ + ...realGetConfigForDocTypeGeneric(docType), + canBeUpdated, + }) as any, ); mockUseRole.mockReturnValue(role); @@ -397,8 +425,12 @@ describe('DocumentView', () => { }); it('does not show reassign button when document type does not support it', () => { - vi.mocked(getConfigForDocType).mockReturnValue( - buildDocumentConfig({ canBeUpdated: false }), + vi.mocked(getConfigForDocTypeGeneric).mockImplementation( + (docType) => + ({ + ...realGetConfigForDocTypeGeneric(docType), + canBeUpdated: false, + }) as any, ); renderComponent(); @@ -446,6 +478,33 @@ describe('DocumentView', () => { }), ); }); + + it('navigates to version history page when version history action is triggered', async () => { + mockUseConfig.mockReturnValue({ + featureFlags: { + versionHistoryEnabled: true, + documentCorrectEnabled: true, + }, + }); + + renderComponent(); + + const versionHistoryLink = screen.getByTestId(ACTION_LINK_KEY.HISTORY); + await userEvent.click(versionHistoryLink); + + await waitFor(() => { + expect(mockUseNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: routeChildren.DOCUMENT_VERSION_HISTORY, + }), + expect.objectContaining({ + state: expect.objectContaining({ + documentReference: mockDocumentReference, + }), + }), + ); + }); + }); }); describe('Role-based rendering', () => { @@ -470,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 80f3982688..4aec0d52c5 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx @@ -1,51 +1,79 @@ -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, getConfigForDocType } from '../../../../helpers/utils/documentType'; +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, + getLloydGeorgeRecordLinks, getRecordActionLinksAllowedForRole, LGRecordActionLink, - lloydGeorgeRecordLinks, - RECORD_ACTION, + 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(); - const documentConfig = getConfigForDocType( + const documentConfig = getConfigForDocTypeGeneric( documentReference?.documentSnomedCodeType ?? DOCUMENT_TYPE.LLOYD_GEORGE, ); 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 => { @@ -64,7 +92,7 @@ const DocumentView = ({ return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); }; - }, [session, setUserSession]); + }, [session, setUserSession, documentReference, getPdfObjectUrl]); if (!documentReference) { navigate(routes.PATIENT_DOCUMENTS); @@ -116,7 +144,7 @@ const DocumentView = ({ }; const getLinks = (): Array => { - if (session.isFullscreen) { + if (session.isFullscreen || viewState === DOCUMENT_VIEW_STATE.VERSION_HISTORY) { return []; } @@ -126,35 +154,44 @@ const DocumentView = ({ !patientDetails?.deceased && (role === REPOSITORY_ROLE.GP_ADMIN || role === REPOSITORY_ROLE.GP_CLINICAL); - const inputLinks = lloydGeorgeRecordLinks.map((link) => { - return { - ...link, - href: undefined, - onClick: link.type === RECORD_ACTION.DOWNLOAD ? downloadClicked : removeClicked, - } as LGRecordActionLink; - }); + const inputLinks = getLloydGeorgeRecordLinks([ + { + key: ACTION_LINK_KEY.DOWNLOAD, + onClick: downloadClicked, + }, + { + key: ACTION_LINK_KEY.DELETE, + onClick: removeClicked, + }, + ]); if (canAddFiles) { - inputLinks.push({ - index: 2, - label: documentConfig.content.addFilesLinkLabel as string, - key: ACTION_LINK_KEY.ADD, - type: RECORD_ACTION.UPDATE, - unauthorised: [], - onClick: handleAddFilesClick, - showIfRecordInStorage: true, - }); + inputLinks.push( + AddAction( + documentConfig.content.getValue('addFilesLinkLabel')!, + handleAddFilesClick, + ), + ); if (config.featureFlags.documentCorrectEnabled) { - inputLinks.push({ - index: 3, - label: documentConfig.content.reassignPagesLinkLabel as string, - key: ACTION_LINK_KEY.REASSIGN, - type: RECORD_ACTION.UPDATE, - unauthorised: [], - onClick: handleReassignPagesClick, - showIfRecordInStorage: true, - }); + const label = documentConfig.content.getValue('reassignPagesLinkLabel')!; + inputLinks.push(ReassignAction(label, handleReassignPagesClick)); + } + + if (config.featureFlags.versionHistoryEnabled) { + const versionHistoryLabel = documentConfig.content.getValue( + 'versionHistoryLinkLabel', + )!; + const vhDescription = documentConfig.content.getValue( + 'versionHistoryLinkDescription', + )!; + inputLinks.push( + VersionHistoryAction( + versionHistoryLabel, + vhDescription, + handleVersionHistoryClick, + ), + ); } } @@ -167,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?.(); @@ -213,6 +242,22 @@ const DocumentView = ({ navigate(to, options); }; + const handleVersionHistoryClick = (): void => { + const to: To = { + pathname: routeChildren.DOCUMENT_VERSION_HISTORY, + }; + + const options: NavigateOptions = { + state: { + documentReference, + }, + }; + + setTimeout(() => { + navigate(to, options); + }, 0); + }; + const handleReassignPagesClick = (): void => { const to: To = { pathname: routeChildren.DOCUMENT_REASSIGN_SELECT_PAGES, @@ -228,10 +273,19 @@ const DocumentView = ({ }, 0); }; + 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}

)} @@ -333,6 +397,15 @@ const DocumentView = ({ + {viewState === DOCUMENT_VIEW_STATE.VERSION_HISTORY && !isActiveVersion && ( + + )} + {session.isFullscreen && showMenu && recordCardLinks()}

diff --git a/app/src/components/generic/recordMenuCard/RecordMenuCard.test.tsx b/app/src/components/generic/recordMenuCard/RecordMenuCard.test.tsx index ffbfce6c63..d722db06df 100644 --- a/app/src/components/generic/recordMenuCard/RecordMenuCard.test.tsx +++ b/app/src/components/generic/recordMenuCard/RecordMenuCard.test.tsx @@ -1,7 +1,11 @@ import { render, screen } from '@testing-library/react'; import RecordMenuCard from './RecordMenuCard'; import useRole from '../../../helpers/hooks/useRole'; -import { LGRecordActionLink, RECORD_ACTION } from '../../../types/blocks/lloydGeorgeActions'; +import { + ACTION_LINK_KEY, + LGRecordActionLink, + RECORD_ACTION, +} from '../../../types/blocks/lloydGeorgeActions'; import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; import { LinkProps } from 'react-router-dom'; import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; @@ -17,7 +21,7 @@ const mockLinks: Array = [ { index: 1, label: 'Remove files', - key: 'delete-all-files-link', + key: ACTION_LINK_KEY.DELETE, type: RECORD_ACTION.UPDATE, stage: LG_RECORD_STAGE.DELETE_ALL, unauthorised: [REPOSITORY_ROLE.GP_CLINICAL], @@ -26,7 +30,7 @@ const mockLinks: Array = [ { index: 0, label: 'Download files', - key: 'download-all-files-link', + key: ACTION_LINK_KEY.DOWNLOAD, type: RECORD_ACTION.DOWNLOAD, stage: LG_RECORD_STAGE.DOWNLOAD_ALL, unauthorised: [], diff --git a/app/src/components/generic/timeline/Timeline.test.tsx b/app/src/components/generic/timeline/Timeline.test.tsx new file mode 100644 index 0000000000..385b1f0ea8 --- /dev/null +++ b/app/src/components/generic/timeline/Timeline.test.tsx @@ -0,0 +1,146 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import Timeline, { TimelineStatus } from './Timeline'; + +describe('Timeline', () => { + it('renders an ordered list with the nhsapp-timeline class', () => { + const { container } = render( + +
  • item
  • +
    , + ); + const ol = container.querySelector('ol.nhsapp-timeline'); + expect(ol).toBeInTheDocument(); + }); + + it('renders its children', () => { + render( + + +

    Hello

    +
    +
    , + ); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); +}); + +describe('Timeline.Item', () => { + it('renders an
  • with nhsapp-timeline__item class', () => { + const { container } = render( +
      + content +
    , + ); + expect(container.querySelector('li.nhsapp-timeline__item')).toBeInTheDocument(); + }); + + it('renders the active badge (filled circle) when status is Active', () => { + const { container } = render( +
      + content +
    , + ); + const badge = container.querySelector('.nhsapp-timeline__badge'); + expect(badge).toBeInTheDocument(); + expect(badge).not.toHaveClass('nhsapp-timeline__badge--small'); + }); + + it('renders the inactive badge (small hollow circle) when status is Inactive', () => { + const { container } = render( +
      + content +
    , + ); + const badge = container.querySelector('.nhsapp-timeline__badge--small'); + expect(badge).toBeInTheDocument(); + }); + + it('renders no badge when status is None', () => { + const { container } = render( +
      + content +
    , + ); + expect(container.querySelector('.nhsapp-timeline__badge')).not.toBeInTheDocument(); + }); + + it('defaults to Inactive (small badge) when no status is provided', () => { + const { container } = render( +
      + content +
    , + ); + expect(container.querySelector('.nhsapp-timeline__badge--small')).toBeInTheDocument(); + }); + + it('applies additional className to the
  • ', () => { + const { container } = render( +
      + content +
    , + ); + expect(container.querySelector('li')).toHaveClass('extra-class'); + }); + + it('wraps children in nhsapp-timeline__content div', () => { + const { container } = render( +
      + inner +
    , + ); + expect(container.querySelector('.nhsapp-timeline__content')).toHaveTextContent('inner'); + }); +}); + +describe('Timeline.Heading', () => { + it('renders an

    with nhsapp-timeline__header class', () => { + const { container } = render(My heading); + expect(container.querySelector('h3.nhsapp-timeline__header')).toBeInTheDocument(); + }); + + it('renders children text', () => { + render(Version 3); + expect(screen.getByText('Version 3')).toBeInTheDocument(); + }); + + it('applies nhsuk-u-font-weight-bold when status is Active', () => { + const { container } = render( + Active heading, + ); + expect(container.querySelector('h3')).toHaveClass('nhsuk-u-font-weight-bold'); + }); + + it('does not apply bold class when status is Inactive', () => { + const { container } = render( + Inactive heading, + ); + expect(container.querySelector('h3')).not.toHaveClass('nhsuk-u-font-weight-bold'); + }); + + it('applies additional className to the heading', () => { + const { container } = render( + Heading, + ); + expect(container.querySelector('h3')).toHaveClass('nhsuk-heading-m'); + }); +}); + +describe('Timeline.Description', () => { + it('renders a

    with nhsapp-timeline__description class', () => { + const { container } = render(Some description); + expect(container.querySelector('p.nhsapp-timeline__description')).toBeInTheDocument(); + }); + + it('renders children text', () => { + render(This is the current version); + expect(screen.getByText('This is the current version')).toBeInTheDocument(); + }); + + it('applies additional className', () => { + const { container } = render( + desc, + ); + expect(container.querySelector('p')).toHaveClass('extra'); + }); +}); diff --git a/app/src/components/generic/timeline/Timeline.tsx b/app/src/components/generic/timeline/Timeline.tsx new file mode 100644 index 0000000000..162e69153b --- /dev/null +++ b/app/src/components/generic/timeline/Timeline.tsx @@ -0,0 +1,97 @@ +import React, { JSX } from 'react'; + +export enum TimelineStatus { + Active = 'active', + Inactive = 'inactive', + None = 'none', +} + +type TimelineProps = { + children: React.ReactNode; +}; + +type TimelineItemProps = { + status?: TimelineStatus; + children?: React.ReactNode; + className?: string; +}; + +type TimelineDescriptionProps = { + className?: string; + children: React.ReactNode; +}; + +type TimelineHeadingProps = { + status?: TimelineStatus; + className?: string; + children: React.ReactNode; +}; + +const ActiveBadge = (): JSX.Element => ( + +); + +const InactiveBadge = (): JSX.Element => ( + +); + +const TimelineHeading = ({ + status = TimelineStatus.Inactive, + className = '', + children, +}: TimelineHeadingProps): JSX.Element => ( +

    + {children} +

    +); + +const TimelineDescription = ({ + children, + className = '', +}: TimelineDescriptionProps): JSX.Element => ( +

    {children}

    +); + +const TimelineItem = ({ + status = TimelineStatus.Inactive, + className = '', + children, +}: TimelineItemProps): JSX.Element => ( +
  • + {status === TimelineStatus.Active ? : <>} + {status === TimelineStatus.Inactive ? : <>} +
    {children}
    +
  • +); + +const Timeline = ({ children }: TimelineProps): JSX.Element => ( +
      {children}
    +); + +Timeline.Item = TimelineItem; +Timeline.Description = TimelineDescription; +Timeline.Heading = TimelineHeading; + +export default Timeline; diff --git a/app/src/config/lloydGeorgeConfig.json b/app/src/config/lloydGeorgeConfig.json index b1f1e119c2..4819b3af54 100644 --- a/app/src/config/lloydGeorgeConfig.json +++ b/app/src/config/lloydGeorgeConfig.json @@ -16,7 +16,7 @@ "PDF" ], "content": { - "viewDocumentTitle": "Scanned paper notes", + "viewDocumentTitle": "Scanned paper notes: Version {version}", "addFilesSelectTitle": "Add files to these scanned paper notes", "uploadFilesSelectTitle": "Choose scanned paper notes to upload", "uploadFilesBulletPoints": [ @@ -43,6 +43,11 @@ "choosePagesToRemoveWarning": "These notes may contain pages for more than one other patient that you want to remove. Only remove pages for one patient at a time.", "addFilesLinkLabel": "Add files to this patient's notes", "reassignPagesLinkLabel": "Reassign pages in these notes to another patient", - "chosenToRemovePagesSubtitle": "You have chosen to remove these pages from the scanned paper notes:" + "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}", + "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/getDocumentSearchResults.ts b/app/src/helpers/requests/getDocumentSearchResults.ts index 71840cb4ef..fd29758ea6 100644 --- a/app/src/helpers/requests/getDocumentSearchResults.ts +++ b/app/src/helpers/requests/getDocumentSearchResults.ts @@ -47,7 +47,7 @@ const getDocumentSearchResults = async ({ author: 'Y12345', id: 'mock-document-id-1', fileSize: 1024, - version: '1.0', + version: '1', documentSnomedCodeType: DOCUMENT_TYPE.LLOYD_GEORGE, contentType: 'application/pdf', }, diff --git a/app/src/helpers/requests/getDocumentVersionHistory.test.ts b/app/src/helpers/requests/getDocumentVersionHistory.test.ts new file mode 100644 index 0000000000..e3529e0ae4 --- /dev/null +++ b/app/src/helpers/requests/getDocumentVersionHistory.test.ts @@ -0,0 +1,212 @@ +import axios from 'axios'; +import { beforeEach, describe, expect, it, Mocked, vi } from 'vitest'; +import { AuthHeaders } from '../../types/blocks/authHeaders'; +import { Bundle } from '../../types/fhirR4/bundle'; +import { + DocumentReferenceStatus, + FhirDocumentReference, +} from '../../types/fhirR4/documentReference'; +import { endpoints } from '../../types/generic/endpoints'; +import { + GetDocumentVersionHistoryArgs, + getDocumentVersionHistoryResponse, +} from './getDocumentVersionHistory'; + +vi.mock('axios'); +vi.mock('../utils/isLocal', () => ({ + isLocal: false, + isMock: (): boolean => false, + isRunningInCypress: (): boolean => false, +})); +const mockedAxios = axios as Mocked; + +describe('getDocumentVersionHistoryResponse', () => { + const mockArgs: GetDocumentVersionHistoryArgs = { + nhsNumber: '1234567890', + baseUrl: 'https://api.example.com', + baseHeaders: { 'Content-Type': 'application/json', test: 'test' } as AuthHeaders, + documentReferenceId: 'doc-ref-123', + }; + + const mockResponse: Bundle = { + resourceType: 'Bundle', + type: 'history', + total: 2, + entry: [ + { + fullUrl: 'urn:uuid:doc-ref-123', + resource: { + resourceType: 'DocumentReference', + id: 'doc-ref-123', + meta: { + versionId: '2', + lastUpdated: '2025-12-15T10:30:00Z', + }, + status: DocumentReferenceStatus.Current, + type: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '16521000000101', + display: 'Lloyd George record folder', + }, + ], + }, + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: '1234567890', + }, + }, + date: '2025-12-15T10:30:00Z', + custodian: { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y12345', + }, + display: 'Y12345', + }, + content: [ + { + attachment: { + contentType: 'application/pdf', + size: 2048, + title: 'document_v2.pdf', + creation: '2025-12-15T10:30:00Z', + }, + }, + ], + }, + }, + { + fullUrl: 'urn:uuid:doc-ref-123', + resource: { + resourceType: 'DocumentReference', + id: 'doc-ref-123', + meta: { + versionId: '1', + lastUpdated: '2025-10-01T09:00:00Z', + }, + status: DocumentReferenceStatus.Superseded, + type: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '16521000000101', + display: 'Lloyd George record folder', + }, + ], + }, + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: '1234567890', + }, + }, + date: '2025-10-01T09:00:00Z', + custodian: { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'A12345', + }, + display: 'A12345', + }, + content: [ + { + attachment: { + contentType: 'application/pdf', + size: 1024, + title: 'document_v1.pdf', + creation: '2025-10-01T09:00:00Z', + }, + }, + ], + }, + }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should successfully fetch document version history and return response', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockResponse }); + + const result = await getDocumentVersionHistoryResponse(mockArgs); + + expect(mockedAxios.get).toHaveBeenCalledWith( + `${mockArgs.baseUrl}${endpoints.DOCUMENT_REFERENCE}/${mockArgs.documentReferenceId}/_history`, + { + 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); + + await expect(getDocumentVersionHistoryResponse(mockArgs)).rejects.toThrow(mockError); + }); + + it('should construct correct URL with documentReferenceId and _history suffix', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockResponse }); + + await getDocumentVersionHistoryResponse(mockArgs); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining(`/${mockArgs.documentReferenceId}/_history`), + expect.any(Object), + ); + }); + + it('should pass correct parameters including patientId', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockResponse }); + + await getDocumentVersionHistoryResponse(mockArgs); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: { + patientId: mockArgs.nhsNumber, + }, + }), + ); + }); + + it('should return response with correct bundle structure', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockResponse }); + + const result = await getDocumentVersionHistoryResponse(mockArgs); + + expect(result.resourceType).toBe('Bundle'); + expect(result.type).toBe('history'); + expect(result.total).toBe(2); + expect(result.entry).toHaveLength(2); + }); + + describe('when isLocal is true', () => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock('../utils/isLocal', () => ({ + isLocal: true, + isMock: (): boolean => false, + isRunningInCypress: (): boolean => false, + })); + }); + + it('should return mock entries ordered by version descending', async () => { + const module = await import('./getDocumentVersionHistory'); + const result = await module.getDocumentVersionHistoryResponse(mockArgs); + + const versions = result.entry!.map((e) => e.resource.meta?.versionId); + expect(versions).toEqual(['3', '2', '1']); + }); + }); +}); diff --git a/app/src/helpers/requests/getDocumentVersionHistory.ts b/app/src/helpers/requests/getDocumentVersionHistory.ts new file mode 100644 index 0000000000..8f9bb53063 --- /dev/null +++ b/app/src/helpers/requests/getDocumentVersionHistory.ts @@ -0,0 +1,44 @@ +import axios, { AxiosError } from 'axios'; +import { AuthHeaders } from '../../types/blocks/authHeaders'; +import { Bundle } from '../../types/fhirR4/bundle'; +import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; +import { endpoints } from '../../types/generic/endpoints'; +import { mockDocumentVersionHistoryResponse } from '../test/getMockVersionHistory'; +import { isLocal } from '../utils/isLocal'; + +export type { FhirDocumentReference as DocumentReference } from '../../types/fhirR4/documentReference'; + +export type GetDocumentVersionHistoryArgs = { + nhsNumber: string; + baseUrl: string; + baseHeaders: AuthHeaders; + documentReferenceId: string; +}; + +export const getDocumentVersionHistoryResponse = async ({ + nhsNumber, + baseUrl, + baseHeaders, + documentReferenceId, +}: GetDocumentVersionHistoryArgs): Promise> => { + const gatewayUrl = baseUrl + endpoints.DOCUMENT_REFERENCE + `/${documentReferenceId}/_history`; + + try { + const { data } = await axios.get>(gatewayUrl, { + headers: { + ...baseHeaders, + }, + params: { + patientId: nhsNumber, + }, + }); + + return data; + } catch (e) { + if (isLocal) { + return mockDocumentVersionHistoryResponse; + } + const error = e as AxiosError; + throw error; + } +}; diff --git a/app/src/helpers/requests/getReviews.ts b/app/src/helpers/requests/getReviews.ts index f9b1e9f6d9..88078fda13 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 new file mode 100644 index 0000000000..b22355cfcd --- /dev/null +++ b/app/src/helpers/test/getMockVersionHistory.ts @@ -0,0 +1,176 @@ +import { Bundle } from '../../types/fhirR4/bundle'; +import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; +import { DocumentReferenceStatus } from '../../types/fhirR4/documentReference'; + +export const mockDocumentVersionHistoryResponse: Bundle = { + resourceType: 'Bundle', + type: 'history', + total: 3, + entry: [ + { + fullUrl: 'urn:uuid:2a7a270e-aa1d-532e-8648-d5d8e3defb82', + resource: { + resourceType: 'DocumentReference', + id: '2a7a270e-aa1d-532e-8648-d5d8e3defb82', + meta: { + versionId: '3', + lastUpdated: '2025-12-15T10:30:00Z', + }, + status: DocumentReferenceStatus.Current, + type: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '16521000000101', + display: 'Lloyd George record folder', + }, + ], + }, + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: '9000000009', + }, + }, + date: '2025-12-15T10:30:00Z', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y12345', + }, + display: 'Y12345', + }, + ], + custodian: { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y12345', + }, + display: 'Y12345', + }, + content: [ + { + attachment: { + contentType: 'application/pdf', + url: '/dev/testFile1.pdf', + size: 3072, + title: 'document_v3.pdf', + creation: '2025-12-15T10:30:00Z', + }, + }, + ], + }, + }, + { + fullUrl: 'urn:uuid:c889dbbf-2e3a-5860-ab90-9421b5e29b86', + resource: { + resourceType: 'DocumentReference', + id: 'c889dbbf-2e3a-5860-ab90-9421b5e29b86', + meta: { + versionId: '2', + lastUpdated: '2025-11-10T14:00:00Z', + }, + status: DocumentReferenceStatus.Superseded, + type: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '16521000000101', + display: 'Lloyd George record folder', + }, + ], + }, + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: '9000000009', + }, + }, + date: '2025-11-10T14:00:00Z', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y12345', + }, + display: 'Y12345', + }, + ], + custodian: { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y12345', + }, + display: 'Y12345', + }, + content: [ + { + attachment: { + contentType: 'application/pdf', + url: '/dev/testFile.pdf', + size: 2048, + title: 'document_v2.pdf', + creation: '2025-11-10T14:00:00Z', + }, + }, + ], + }, + }, + { + fullUrl: 'urn:uuid:232865e2-c1b5-58c5-bc1c-9d355907b649', + resource: { + resourceType: 'DocumentReference', + id: '232865e2-c1b5-58c5-bc1c-9d355907b649', + meta: { + versionId: '1', + lastUpdated: '2025-10-01T09:00:00Z', + }, + status: DocumentReferenceStatus.Superseded, + type: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '16521000000101', + display: 'Lloyd George record folder', + }, + ], + }, + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: '9000000009', + }, + }, + date: '2025-10-01T09:00:00Z', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'A12345', + }, + display: 'A12345', + }, + ], + custodian: { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'A12345', + }, + display: 'A12345', + }, + content: [ + { + attachment: { + contentType: 'application/pdf', + url: '/dev/testFile3.pdf', + size: 1024, + title: 'document_v1.pdf', + creation: '2025-10-01T09:00:00Z', + }, + }, + ], + }, + }, + ], +}; diff --git a/app/src/helpers/test/testBuilders.ts b/app/src/helpers/test/testBuilders.ts index 947cca3c9b..052a088bcf 100644 --- a/app/src/helpers/test/testBuilders.ts +++ b/app/src/helpers/test/testBuilders.ts @@ -18,7 +18,12 @@ import { DeceasedAccessAuditReasons, PatientAccessAudit, } from '../../types/generic/accessAudit'; -import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../utils/documentType'; +import { + DOCUMENT_TYPE, + DOCUMENT_TYPE_CONFIG, + DOCUMENT_TYPE_CONFIG_GENERIC, + LGContentKeys, +} from '../utils/documentType'; import { ReviewsResponse } from '../../types/generic/reviews'; import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; @@ -179,7 +184,7 @@ const buildPatientAccessAudit = (): PatientAccessAudit[] => { const buildDocumentConfig = ( configOverride?: Partial, -): DOCUMENT_TYPE_CONFIG => { +): DOCUMENT_TYPE_CONFIG | DOCUMENT_TYPE_CONFIG_GENERIC => { return { snomedCode: DOCUMENT_TYPE.LLOYD_GEORGE, displayName: 'Scanned Paper Notes', diff --git a/app/src/helpers/utils/documentType.test.ts b/app/src/helpers/utils/documentType.test.ts index 50d1ca4c5f..5da9dd5f5a 100644 --- a/app/src/helpers/utils/documentType.test.ts +++ b/app/src/helpers/utils/documentType.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect } from 'vitest'; -import { DOCUMENT_TYPE, getDocumentTypeLabel, getConfigForDocType } from './documentType'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + DOCUMENT_TYPE, + createDocumentTypeContent, + getDocumentTypeLabel, + getConfigForDocType, + getConfigForDocTypeGeneric, + type LGContentKeys, +} from './documentType'; describe('documentType', () => { describe('getDocumentTypeLabel', () => { @@ -63,4 +70,128 @@ describe('documentType', () => { ); }); }); + + describe('createDocumentTypeContent', () => { + const content = { + title: 'My Title', + description: 'Hello {name}, you have {count} items.', + list: ['item one', 'item two'], + } as const; + + type TestKeys = keyof typeof content; + + let util: ReturnType< + typeof createDocumentTypeContent + >; + let warnSpy: ReturnType; + + beforeEach(() => { + util = createDocumentTypeContent( + content as Record, + ); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + describe('direct property access', () => { + it('exposes string values as direct properties', () => { + expect(util.title).toBe('My Title'); + }); + + it('exposes array values as direct properties', () => { + expect(util.list).toEqual(['item one', 'item two']); + }); + }); + + describe('getValue', () => { + it('returns the value for a known key', () => { + expect(util.getValue('title')).toBe('My Title'); + }); + + it('returns an array value for a known key', () => { + expect(util.getValue('list')).toEqual(['item one', 'item two']); + }); + + it('returns empty string and warns when the key is missing', () => { + const result = util.getValue('nonExistent' as TestKeys); + expect(result).toBe(''); + expect(warnSpy).toHaveBeenCalledWith( + 'Content key "nonExistent" not found in document type content.', + ); + }); + }); + + describe('getValueFormatString', () => { + it('replaces a single placeholder with the matching object property', () => { + const titleUtil = createDocumentTypeContent({ msg: 'Hello {name}' }); + const result = titleUtil.getValueFormatString('msg', { name: 'Alice' }); + expect(result).toBe('Hello Alice'); + }); + + it('replaces multiple placeholders in one string', () => { + const result = util.getValueFormatString('description', { + name: 'Bob', + count: 3, + }); + expect(result).toBe('Hello Bob, you have 3 items.'); + }); + + it('leaves a placeholder unchanged when the matching key is absent from obj', () => { + const result = util.getValueFormatString('description', { name: 'Carol' }); + expect(result).toBe('Hello Carol, you have {count} items.'); + }); + + it('returns the raw string unchanged when there are no placeholders', () => { + const result = util.getValueFormatString('title', {}); + expect(result).toBe('My Title'); + }); + + it('returns the array value unchanged when the value is not a string', () => { + const result = util.getValueFormatString('list', { name: 'anyone' }); + expect(result).toEqual(['item one', 'item two']); + }); + + it('returns undefined and warns when the key is missing', () => { + const result = util.getValueFormatString('nonExistent' as TestKeys, {}); + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + 'Content key "nonExistent" not found in document type content.', + ); + }); + }); + }); + + describe('getConfigForDocTypeGeneric', () => { + it('returns a typed config for LLOYD_GEORGE with LG-specific keys accessible', () => { + const config = getConfigForDocTypeGeneric(DOCUMENT_TYPE.LLOYD_GEORGE); + expect(config.snomedCode).toBe(DOCUMENT_TYPE.LLOYD_GEORGE); + const label = config.content.getValue('versionHistoryLinkLabel'); + expect(typeof label).toBe('string'); + expect(label!.length).toBeGreaterThan(0); + }); + + it('returns config for EHR', () => { + const config = getConfigForDocTypeGeneric(DOCUMENT_TYPE.EHR); + expect(config.snomedCode).toBe(DOCUMENT_TYPE.EHR); + }); + + it('returns config for EHR_ATTACHMENTS', () => { + const config = getConfigForDocTypeGeneric(DOCUMENT_TYPE.EHR_ATTACHMENTS); + expect(config.snomedCode).toBe(DOCUMENT_TYPE.EHR_ATTACHMENTS); + }); + + it('returns config for LETTERS_AND_DOCS', () => { + const config = getConfigForDocTypeGeneric(DOCUMENT_TYPE.LETTERS_AND_DOCS); + expect(config.snomedCode).toBe(DOCUMENT_TYPE.LETTERS_AND_DOCS); + }); + + it('falls back to getConfigForDocType for unknown type', () => { + expect(() => getConfigForDocTypeGeneric('unknown' as DOCUMENT_TYPE)).toThrow( + 'No config found for document type: unknown', + ); + }); + }); }); diff --git a/app/src/helpers/utils/documentType.ts b/app/src/helpers/utils/documentType.ts index e1bbb52a21..8822a548a4 100644 --- a/app/src/helpers/utils/documentType.ts +++ b/app/src/helpers/utils/documentType.ts @@ -1,17 +1,64 @@ import lloydGeorgeConfig from '../../config/lloydGeorgeConfig.json'; import electronicHealthRecordConfig from '../../config/electronicHealthRecordConfig.json'; -import electronicHealthRecordAttachmentsConfig from '../../config/electronicHealthRecordAttachmentsConfig.json'; +import ehrAttachmentsConfiguration from '../../config/electronicHealthRecordAttachmentsConfig.json'; import lettersAndDocumentsConfig from '../../config/lettersAndDocumentsConfig.json'; +/** + * SNOMED codes identifying each document type supported by the system. + * These values are used as keys in API requests and document references. + */ export enum DOCUMENT_TYPE { LLOYD_GEORGE = '16521000000101', - EHR = '717301000000104', // TBC - EHR_ATTACHMENTS = '24511000000107', // TBC - LETTERS_AND_DOCS = '162931000000103', // TBC - ALL = '16521000000101,717301000000104,24511000000107,162931000000103', // TBC + EHR = '717301000000104', + EHR_ATTACHMENTS = '24511000000107', + LETTERS_AND_DOCS = '162931000000103', + ALL = '16521000000101,717301000000104,24511000000107,162931000000103', } -export type ContentKey = +/** + * Content keys available to Lloyd George documents. + * Extends `ContentKeys` with LG-specific keys for version history UI elements. + */ +export type LGContentKeys = + | ContentKeys + | 'versionHistoryLinkLabel' + | 'versionHistoryLinkDescription' + | 'searchResultDocumentTypeLabel' + | 'versionHistoryHeader' + | 'versionHistoryTimelineHeader'; +/** Content keys available to Electronic Health Record documents. */ +export type EhrContentKeys = ContentKeys; +/** Content keys available to EHR Attachments documents. */ +export type EhrAttachmentsContentKeys = ContentKeys; +/** Content keys available to Letters and Documents. */ +export type LettersAndDocsContentKeys = ContentKeys; + +/** + * Union of all content keys across every document type. + * Use this when working with a config that may be any doc type (e.g. state typed + * without knowing the doc type at compile time). + * + * Note: Because `LGContentKeys` adds extra keys beyond `ContentKeys`, `AllContentKeys` + * is a superset of `ContentKeys`. A `DOCUMENT_TYPE_CONFIG_GENERIC` is + * therefore NOT assignable to `DOCUMENT_TYPE_CONFIG_GENERIC` — use + * `getConfigForDocTypeGeneric` with the appropriate `T` instead. + */ +export type AllContentKeys = + | LGContentKeys + | EhrContentKeys + | EhrAttachmentsContentKeys + | LettersAndDocsContentKeys; + +/** + * The base set of content keys shared by every document type. + * These map to string values (or arrays of strings) stored in each doc type's + * JSON config file (e.g. lloydGeorgeConfig.json). + * + * To add a new key shared across all doc types, add it here AND to every + * JSON config file. For a key specific to one doc type, extend the relevant + * `*ContentKeys` type instead (e.g. `LGContentKeys`). + */ +export type ContentKeys = | 'reviewDocumentTitle' | 'viewDocumentTitle' | 'addFilesSelectTitle' @@ -34,17 +81,144 @@ export type ContentKey = | 'choosePagesToRemoveTitle' | 'choosePagesToRemoveWarning' | 'addFilesLinkLabel' - | 'reassignPagesLinkLabel' - | 'chosenToRemovePagesSubtitle'; -export interface IndividualDocumentTypeContent extends Record {} + | 'chosenToRemovePagesSubtitle' + | 'reassignPagesLinkLabel'; + +export interface IndividualDocumentTypeContent extends Record {} + +/** + * A type-safe content store for a document type's UI strings. + * + * It is both a `Record` (so keys are accessible as direct properties, e.g. + * `content.previewUploadTitle`) and exposes a `getValue` helper for cases where + * the key is a variable or a narrower subtype needs to be asserted. + * + * @typeParam K - The union of valid content key strings for this doc type + * (e.g. `LGContentKeys`, `ContentKeys`). + * @typeParam V - The value type stored under each key (typically `string | string[]`). + * + * @example Direct property access (preferred for known keys): + * ```ts + * config.content.previewUploadTitle // string | string[] + * ``` + * + * @example `getValue` with a narrowed key type (useful for doc-type-specific keys): + * ```ts + * config.content.getValue('versionHistoryLinkLabel') + * ``` + */ +export type IndividualDocumentTypeContentUtil = Record & { + /** + * Retrieves a content value by key with an optional return type assertion. + * + * @typeParam TReturn - Narrows the return type (defaults to `V & string`). + * @typeParam TKeys - Constrains which keys are accepted (defaults to all `K`). + * Pass a more specific key union (e.g. `LGContentKeys`) when + * accessing keys that only exist on a particular doc type. + * @param key - The content key to look up. + */ + getValue(key: TKeys): TReturn | undefined; + /** + * Retrieves a content value by key with an optional return type assertion. + * + * @typeParam TReturn - Narrows the return type (defaults to `V & string`). + * @typeParam TKeys - Constrains which keys are accepted (defaults to all `K`). + * Pass a more specific key union (e.g. `LGContentKeys`) when + * accessing keys that only exist on a particular doc type. + * @param key - The content key to look up. + * @param obj - An object whose properties will be used to replace placeholders in the content string. + * For example, with content "Hello {name}" and obj = { name: "Alice" }, the returned string would be "Hello Alice". + */ + getValueFormatString( + key: TKeys, + obj: object, + ): TReturn | undefined; +}; + +/** + * Convenience interface representing a fully-populated content util containing + * every possible content key across all document types (`AllContentKeys`). + * Useful as a loose type when the specific doc type is not known. + */ +export interface IndividualDocumentTypeContent extends IndividualDocumentTypeContentUtil< + AllContentKeys, + string | string[] +> {} -// The individual config for each document type -export type DOCUMENT_TYPE_CONFIG = { +/** + * Factory that builds an `IndividualDocumentTypeContentUtil` from a plain record. + * + * The returned object spreads all key/value pairs onto itself so they are + * accessible as direct properties, and attaches a `getValue` method. + * + * This is an internal helper — consumers should call `getConfigForDocType` or + * `getConfigForDocTypeGeneric` rather than using this directly. + * + * @typeParam K - The union of content key strings. + * @typeParam V - The value type (typically `string | string[]`). + */ +export const createDocumentTypeContent = ( + content: Record, +): IndividualDocumentTypeContentUtil => ({ + ...content, + getValue(key: TKeys): TReturn { + const value = content[key as K] as V | undefined; + if (!value) { + // eslint-disable-next-line no-console + console.warn(`Content key "${key}" not found in document type content.`); + return '' as TReturn; + } + return value as TReturn; + }, + getValueFormatString( + key: TKeys, + obj: object, + ): TReturn | undefined { + const value = content[key as K] as V | undefined; + // for value for example "Hello {name}" and obj = { name: "Alice" }, replace "{name}" with "Alice" + if (typeof value === 'string') { + const formattedValue = value.replace(/{(\w+)}/g, (_, k) => { + const replacement = obj[k as keyof typeof obj]; + if (replacement === undefined) { + return `{${k}}`; + } + return String(replacement); + }); + return formattedValue as TReturn; + } + if (!value) { + // eslint-disable-next-line no-console + console.warn(`Content key "${key}" not found in document type content.`); + return undefined as TReturn; + } + return value as TReturn; + }, +}); + +/** + * Convenience alias for a config typed with the full `AllContentKeys` union. + * Use this when you don't need to distinguish between doc-type-specific keys + * and just need to pass a config around without caring which doc type it is. + * + * For type-safe access to doc-type-specific keys (e.g. LG-only version history + * keys), use `DOCUMENT_TYPE_CONFIG_GENERIC` instead. + */ +export type DOCUMENT_TYPE_CONFIG = DOCUMENT_TYPE_CONFIG_GENERIC; + +/** + * The full configuration object for a single document type. + * + * @typeParam K - The content key union for this doc type. Using a narrower type + * (e.g. `LGContentKeys`) gives compile-time safety when accessing + * doc-type-specific content keys. Using `AllContentKeys` gives a + * looser type that works for any doc type. + */ +export type DOCUMENT_TYPE_CONFIG_GENERIC = { acceptedFileTypes: string[]; associatedSnomed?: DOCUMENT_TYPE; canBeDiscarded: boolean; canBeUpdated: boolean; - content: IndividualDocumentTypeContent; + content: IndividualDocumentTypeContentUtil; displayName: string; filenameOverride?: string; reviewDocumentsFileNamePrefix?: string; @@ -71,6 +245,7 @@ export interface DocumentType { export type DocumentTypesConfig = DocumentType[]; +/** Returns a human-readable display label for a given document type. */ export const getDocumentTypeLabel = (docType: DOCUMENT_TYPE): string => { switch (docType) { case DOCUMENT_TYPE.LLOYD_GEORGE: @@ -86,17 +261,96 @@ export const getDocumentTypeLabel = (docType: DOCUMENT_TYPE): string => { } }; +/** + * Returns the config for a document type typed as `DOCUMENT_TYPE_CONFIG` (i.e. + * `AllContentKeys`). Use this when you only need the common `ContentKeys` and + * don't require access to doc-type-specific keys. + * + * For full type safety on doc-type-specific keys, use `getConfigForDocTypeGeneric` + * with an explicit type parameter instead. + */ export const getConfigForDocType = (docType: DOCUMENT_TYPE): DOCUMENT_TYPE_CONFIG => { switch (docType) { case DOCUMENT_TYPE.LLOYD_GEORGE: - return lloydGeorgeConfig as DOCUMENT_TYPE_CONFIG; + return getConfigForDocTypeGeneric(DOCUMENT_TYPE.LLOYD_GEORGE) as DOCUMENT_TYPE_CONFIG; case DOCUMENT_TYPE.EHR: - return electronicHealthRecordConfig as DOCUMENT_TYPE_CONFIG; + return getConfigForDocTypeGeneric(DOCUMENT_TYPE.EHR) as DOCUMENT_TYPE_CONFIG; case DOCUMENT_TYPE.EHR_ATTACHMENTS: - return electronicHealthRecordAttachmentsConfig as DOCUMENT_TYPE_CONFIG; + return getConfigForDocTypeGeneric( + DOCUMENT_TYPE.EHR_ATTACHMENTS, + ) as DOCUMENT_TYPE_CONFIG; case DOCUMENT_TYPE.LETTERS_AND_DOCS: - return lettersAndDocumentsConfig as DOCUMENT_TYPE_CONFIG; + return getConfigForDocTypeGeneric( + DOCUMENT_TYPE.LETTERS_AND_DOCS, + ) as DOCUMENT_TYPE_CONFIG; default: throw new Error(`No config found for document type: ${docType}`); } }; + +/** + * Internal intermediate shape used when loading configs from JSON. + * Replaces the typed `content` field with a plain `Record` so that + * `createDocumentTypeContent` can wrap it into an `IndividualDocumentTypeContentUtil`. + */ +type BaseDocTypeConfig = Omit, 'content'> & { + content: Record; +}; + +/** Converts a `BaseDocTypeConfig` (plain record content) into a fully typed `DOCUMENT_TYPE_CONFIG_GENERIC`. */ +const toDocTypeConfig = ( + config: BaseDocTypeConfig, +): DOCUMENT_TYPE_CONFIG_GENERIC => ({ + ...config, + content: createDocumentTypeContent(config.content), +}); + +/** + * Returns the config for a document type with a precise content key type. + * + * Pass a doc-type-specific key union as `T` to get compile-time safety when + * accessing keys that only exist for that doc type: + * + * ```ts + * // Access LG-only keys safely: + * const config = getConfigForDocTypeGeneric(DOCUMENT_TYPE.LLOYD_GEORGE); + * config.content.getValue('versionHistoryLinkLabel'); + * ``` + * + * When `T` is omitted it defaults to `AllContentKeys`, equivalent to calling + * `getConfigForDocType`. + * + * The internal `as unknown as DOCUMENT_TYPE_CONFIG_GENERIC` casts are necessary + * because each switch branch builds a narrowly typed config (e.g. + * `DOCUMENT_TYPE_CONFIG_GENERIC`) that TypeScript cannot prove + * satisfies the caller-supplied `T`. The cast is safe because the caller is asserting + * they know which doc type they are requesting. + * + * @typeParam T - The content key union to use. Must extend `AllContentKeys`. + */ +export const getConfigForDocTypeGeneric = ( + docType: DOCUMENT_TYPE, +): DOCUMENT_TYPE_CONFIG_GENERIC => { + switch (docType) { + case DOCUMENT_TYPE.LLOYD_GEORGE: + return toDocTypeConfig( + lloydGeorgeConfig as BaseDocTypeConfig, + ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; + case DOCUMENT_TYPE.EHR: + return toDocTypeConfig( + electronicHealthRecordConfig as BaseDocTypeConfig, + ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; + case DOCUMENT_TYPE.EHR_ATTACHMENTS: + return toDocTypeConfig( + ehrAttachmentsConfiguration as BaseDocTypeConfig, + ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; + case DOCUMENT_TYPE.LETTERS_AND_DOCS: + return toDocTypeConfig( + lettersAndDocumentsConfig as BaseDocTypeConfig, + ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; + default: + return getConfigForDocType(docType); + } +}; + +export type GetConfigForDocTypeGenericType = typeof getConfigForDocTypeGeneric; diff --git a/app/src/helpers/utils/fhirUtil.test.ts b/app/src/helpers/utils/fhirUtil.test.ts new file mode 100644 index 0000000000..70e2bbd625 --- /dev/null +++ b/app/src/helpers/utils/fhirUtil.test.ts @@ -0,0 +1,266 @@ +import { Bundle } from '../../types/fhirR4/bundle'; +import bundleHistory1Json from '../../types/fhirR4/bundleHistory1.fhir.json'; +import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; +import { DOCUMENT_TYPE } from './documentType'; +import { + getCreatedDate, + getCustodianValue, + getDocumentReferenceFromFhir, + getVersionId, +} from './fhirUtil'; + +const buildDoc = (overrides: Partial = {}): FhirDocumentReference => + ({ resourceType: 'DocumentReference', ...overrides }) as FhirDocumentReference; + +describe('fhirUtil', () => { + describe('helper accessors tested vs bundleHistory1Json', () => { + const doc = (bundleHistory1Json as unknown as Bundle).entry![0] + .resource; + + it('should extract versionId via getVersionId', () => { + expect(getVersionId(doc)).toBe('1'); + }); + + it('should extract created date via getCreatedDate', () => { + expect(getCreatedDate(doc)).toBe('2024-01-10T09:15:00Z'); + }); + + it('should extract custodian value via getCustodianValue', () => { + expect(getCustodianValue(doc)).toBe('A12345'); + }); + }); + + describe('getVersionId', () => { + it('returns empty string when meta is undefined', () => { + expect(getVersionId(buildDoc())).toBe(''); + }); + + it('returns empty string when meta.versionId is undefined', () => { + expect(getVersionId(buildDoc({ meta: {} }))).toBe(''); + }); + + it('returns the versionId when present', () => { + expect(getVersionId(buildDoc({ meta: { versionId: '42' } }))).toBe('42'); + }); + }); + + describe('getCreatedDate', () => { + it('returns empty string when date is undefined', () => { + expect(getCreatedDate(buildDoc())).toBe(''); + }); + + it('returns the date string when present', () => { + expect(getCreatedDate(buildDoc({ date: '2025-06-01T12:00:00Z' }))).toBe( + '2025-06-01T12:00:00Z', + ); + }); + }); + + describe('getCustodianValue', () => { + it('returns empty string when custodian is undefined', () => { + expect(getCustodianValue(buildDoc())).toBe(''); + }); + + it('returns empty string when custodian has no identifier and no display', () => { + expect(getCustodianValue(buildDoc({ custodian: {} }))).toBe(''); + }); + + it('returns identifier value when present', () => { + expect( + getCustodianValue(buildDoc({ custodian: { identifier: { value: 'ODS001' } } })), + ).toBe('ODS001'); + }); + + it('falls back to display when identifier value is undefined', () => { + expect(getCustodianValue(buildDoc({ custodian: { display: 'My Practice' } }))).toBe( + 'My Practice', + ); + }); + + it('returns empty string when identifier value is undefined and display is undefined', () => { + 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', + custodian: { 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 custodian display as fallback for author', () => { + const fhirDoc = buildDoc({ + id: 'doc-display', + custodian: { 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 new file mode 100644 index 0000000000..fd78eecb00 --- /dev/null +++ b/app/src/helpers/utils/fhirUtil.ts @@ -0,0 +1,48 @@ +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 + */ +export const getVersionId = (doc: FhirDocumentReference): string => doc.meta?.versionId ?? ''; + +/** + * Gets the created date from a FHIR R4 DocumentReference + */ +export const getCreatedDate = (doc: FhirDocumentReference): string => doc.date ?? ''; + +/** + * Gets the custodian which will be ODS code value from a FHIR R4 DocumentReference + */ +export const getCustodianValue = (doc: FhirDocumentReference): string => + doc.custodian?.identifier?.value ?? doc.custodian?.display ?? ''; + +export const getDocumentReferenceFromFhir = ( + fhirDocRef: FhirDocumentReference, +): DocumentReference => { + const documentSnomedCodeType = fhirDocRef.type?.coding?.[0]?.code! as DOCUMENT_TYPE; + const created = getCreatedDate(fhirDocRef); + const author = getCustodianValue(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/formatDate.test.ts b/app/src/helpers/utils/formatDate.test.ts index e0a8338051..1f6b8f6e47 100644 --- a/app/src/helpers/utils/formatDate.test.ts +++ b/app/src/helpers/utils/formatDate.test.ts @@ -5,6 +5,7 @@ import { formatDateWithDashes, getFormattedDateFromString, getFormattedDateTimeFromString, + getFormatDateWithAtTime, } from './formatDate'; describe('formatDate.ts', () => { @@ -69,6 +70,24 @@ describe('formatDate.ts', () => { }); }); + describe('getFormatDateWithAtTime', () => { + it('formats a midday ISO date as "D Month YYYY at H:MM am/pm"', () => { + const result = getFormatDateWithAtTime('2024-01-01T14:30:00Z'); + expect(result).toMatch(/^1 January 2024 at \d{1,2}:\d{2} (am|pm)$/); + }); + + it('includes the correct day, month and year', () => { + const result = getFormatDateWithAtTime('2025-12-15T10:30:00Z'); + expect(result).toContain('15 December 2025'); + }); + + it('formats the time in 12-hour lower-case notation', () => { + const result = getFormatDateWithAtTime('2024-06-20T00:00:00Z'); + expect(result).toMatch(/(am|pm)$/); + expect(result).not.toMatch(/(AM|PM)/); + }); + }); + describe('format epoch dates in seconds', () => { it('getFormattedDateTimeFromString formats numeric timestamp strings in seconds', () => { const result = getFormattedDateTimeFromString('1735689600'); diff --git a/app/src/helpers/utils/formatDate.ts b/app/src/helpers/utils/formatDate.ts index 0db77e980d..3558e59e78 100644 --- a/app/src/helpers/utils/formatDate.ts +++ b/app/src/helpers/utils/formatDate.ts @@ -50,3 +50,17 @@ export const getFormattedDateTimeFromString = (dateString: string | undefined): return getFormattedDateTime(getDateFromString(dateString)); }; + +// Example: +// Input: "2024-01-01T14:30:00Z" +// Output: "1 January 2024 at 2:30 pm" +export const getFormatDateWithAtTime = (isoDate: string): string => { + const date = new Date(isoDate); + const day = date.getDate(); + const month = date.toLocaleString('en-GB', { month: 'long' }); + const year = date.getFullYear(); + const time = date + .toLocaleString('en-GB', { hour: 'numeric', minute: '2-digit', hour12: true }) + .toLowerCase(); + return `${day} ${month} ${year} at ${time}`; +}; diff --git a/app/src/helpers/utils/getPdfObjectUrl.ts b/app/src/helpers/utils/getPdfObjectUrl.ts index f270a6e18a..b2c85c7a34 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.test.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx index 3c7de18e26..7a6daf9077 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx @@ -439,7 +439,6 @@ describe('', () => { - , , ); }; diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx index b98092188b..8ebcba8b1d 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,6 +36,7 @@ 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(); @@ -193,10 +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 new file mode 100644 index 0000000000..0e03908a0b --- /dev/null +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx @@ -0,0 +1,353 @@ +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 { 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 { fetchBlob } from '../../helpers/utils/getPdfObjectUrl'; +import { routeChildren, routes } from '../../types/generic/routes'; +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'); +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({ + 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('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(); + + 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(); + }); + }); + + 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', () => { + 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(); + }); + }); + }); + + 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 new file mode 100644 index 0000000000..0638893f88 --- /dev/null +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx @@ -0,0 +1,244 @@ +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 { + getConfigForDocTypeGeneric, + getDocumentTypeLabel, + LGContentKeys, +} from '../../helpers/utils/documentType'; +import { + getCreatedDate, + getCustodianValue, + 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 { 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(); + 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 (!documentReference) { + navigate(routes.PATIENT_DOCUMENTS); + return; + } + if (!versionHistoryRef.current) { + versionHistoryRef.current = true; + const fetchVersionHistory = async (): Promise => { + try { + const response = await getDocumentVersionHistoryResponse({ + nhsNumber, + baseUrl, + baseHeaders, + documentReferenceId: documentReference.id, + }); + 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(); + } + }, [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.

    ; + } + + 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 === versionHistory.entry!.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, isActiveVersion)} + > + View + + ) : ( +
    + + + Restore version + +
    + )} +
    + ); + })} +
    + ); + }; + + return ( +
    + + +

    {pageHeader}

    + + {renderVersionHistoryTimeline()} +
    + ); +}; + +export default DocumentVersionHistoryPage; diff --git a/app/src/styles/App.scss b/app/src/styles/App.scss index e2237639a8..275e64abb9 100644 --- a/app/src/styles/App.scss +++ b/app/src/styles/App.scss @@ -10,6 +10,8 @@ $govuk-compatibility-govukelements: true; @import 'nhsuk-frontend/packages/nhsuk'; +@import 'nhsapp-frontend/dist/nhsapp/components/timeline/_timeline.scss'; + /** * Styleguide: Blocks, Elements and Modifiers * Docs: https://getbem.com/ diff --git a/app/src/types/blocks/lloydGeorgeActions.ts b/app/src/types/blocks/lloydGeorgeActions.ts index 696e69bc94..3a79221db9 100644 --- a/app/src/types/blocks/lloydGeorgeActions.ts +++ b/app/src/types/blocks/lloydGeorgeActions.ts @@ -13,7 +13,7 @@ type ActionRoute = routeChildren | routes; export type LGRecordActionLink = { index: number; label: string; - key: string; + key: ACTION_LINK_KEY; stage?: LG_RECORD_STAGE; href?: ActionRoute; onClick?: () => void; @@ -28,30 +28,89 @@ export enum ACTION_LINK_KEY { DELETE = 'delete-files-link', REASSIGN = 'reassign-pages-link', ADD = 'add-files-link', + HISTORY = 'view-document-history-link', } +const RemoveAction: LGRecordActionLink = { + index: 1, + label: 'Remove this document', + key: ACTION_LINK_KEY.DELETE, + type: RECORD_ACTION.UPDATE, + unauthorised: [REPOSITORY_ROLE.GP_CLINICAL], + href: routeChildren.LLOYD_GEORGE_DELETE, + showIfRecordInStorage: true, + description: 'This action will remove all pages of this document from storage in this service.', +}; + +const DownloadAction: LGRecordActionLink = { + index: 0, + label: 'Download this document', + key: ACTION_LINK_KEY.DOWNLOAD, + type: RECORD_ACTION.DOWNLOAD, + unauthorised: [], + href: routeChildren.LLOYD_GEORGE_DOWNLOAD, + showIfRecordInStorage: true, +}; + +export const AddAction = (label: string, onClick: () => void): LGRecordActionLink => { + return { + index: 2, + label: label, + key: ACTION_LINK_KEY.ADD, + type: RECORD_ACTION.UPDATE, + unauthorised: [], + showIfRecordInStorage: true, + onClick, + }; +}; -export const lloydGeorgeRecordLinks: Array = [ - { - index: 1, - label: 'Remove this document', - key: ACTION_LINK_KEY.DELETE, +export const ReassignAction = (label: string, onClick: () => void): LGRecordActionLink => { + return { + index: 3, + label: label, + key: ACTION_LINK_KEY.REASSIGN, type: RECORD_ACTION.UPDATE, - unauthorised: [REPOSITORY_ROLE.GP_CLINICAL], - href: routeChildren.LLOYD_GEORGE_DELETE, + unauthorised: [], + onClick, showIfRecordInStorage: true, - description: - 'This action will remove all pages of this document from storage in this service.', - }, - { - index: 0, - label: 'Download this document', - key: ACTION_LINK_KEY.DOWNLOAD, - type: RECORD_ACTION.DOWNLOAD, + }; +}; + +export const VersionHistoryAction = ( + label: string, + description: string, + onClick: () => void, +): LGRecordActionLink => { + return { + index: 4, + label: label, + key: ACTION_LINK_KEY.HISTORY, + type: RECORD_ACTION.UPDATE, unauthorised: [], - href: routeChildren.LLOYD_GEORGE_DOWNLOAD, + onClick, showIfRecordInStorage: true, - }, -]; + description, + }; +}; + +export const lloydGeorgeRecordLinks: Array = [RemoveAction, DownloadAction]; + +export type getLloydGeorgeRecordLinksProps = { + key: ACTION_LINK_KEY; + onClick: () => void; +}; + +export function getLloydGeorgeRecordLinks( + mapper: getLloydGeorgeRecordLinksProps[], +): Array { + const lgRecordLinks: Array = lloydGeorgeRecordLinks.map((link) => { + const mappedLink = mapper.find((m) => m.key === link.key); + if (mappedLink) { + return { ...link, onClick: mappedLink.onClick }; + } + return link; + }); + return lgRecordLinks; +} type Args = { role: REPOSITORY_ROLE | null; diff --git a/app/src/types/fhirR4/baseTypes.ts b/app/src/types/fhirR4/baseTypes.ts new file mode 100644 index 0000000000..62a4f3b511 --- /dev/null +++ b/app/src/types/fhirR4/baseTypes.ts @@ -0,0 +1,340 @@ +/** + * FHIR R4 Base Data Types + * @see https://hl7.org/fhir/R4/datatypes.html + * @see https://hl7.org/fhir/R4/references.html + * @see https://hl7.org/fhir/R4/resource.html + */ + +// ─── Element ───────────────────────────────────────────────────────────────── + +/** + * Base definition for all elements in a resource. + * @see https://hl7.org/fhir/R4/element.html + */ +export interface Element { + /** Unique id for inter-element referencing */ + id?: string; + /** Additional content defined by implementations */ + extension?: Extension[]; +} + +// ─── Extension ─────────────────────────────────────────────────────────────── + +/** + * Optional Extensions Element — found in all resources and data types. + * @see https://hl7.org/fhir/R4/extensibility.html#Extension + */ +export interface Extension extends Element { + /** Identifies the meaning of the extension */ + url: string; + + // Each extension can carry ONE of the following value types: + valueBase64Binary?: string; + valueBoolean?: boolean; + valueCanonical?: string; + valueCode?: string; + valueDate?: string; + valueDateTime?: string; + valueDecimal?: number; + valueId?: string; + valueInstant?: string; + valueInteger?: number; + valueMarkdown?: string; + valueOid?: string; + valuePositiveInt?: number; + valueString?: string; + valueTime?: string; + valueUnsignedInt?: number; + valueUri?: string; + valueUrl?: string; + valueAddress?: Address; + valueAttachment?: Attachment; + valueCodeableConcept?: CodeableConcept; + valueCoding?: Coding; + valueContactPoint?: ContactPoint; + valueHumanName?: HumanName; + valueIdentifier?: Identifier; + valuePeriod?: Period; + valueQuantity?: Quantity; + valueRange?: Range; + valueReference?: Reference; +} + +// ─── Resource ──────────────────────────────────────────────────────────────── + +/** + * Metadata about a resource. + * @see https://hl7.org/fhir/R4/resource.html#Meta + */ +export interface Meta extends Element { + /** Version specific identifier */ + versionId?: string; + /** When the resource version last changed */ + lastUpdated?: string; + /** Identifies where the resource comes from */ + source?: string; + /** Profiles this resource claims to conform to */ + profile?: string[]; + /** Security Labels applied to this resource */ + security?: Coding[]; + /** Tags applied to this resource */ + tag?: Coding[]; +} + +/** + * Base Resource — the ancestor of all FHIR resources. + * @see https://hl7.org/fhir/R4/resource.html + */ +export interface Resource { + /** The type of the resource */ + resourceType: string; + /** Logical id of this artifact */ + id?: string; + /** Metadata about the resource */ + meta?: Meta; + /** A set of rules under which this content was created */ + implicitRules?: string; + /** Language of the resource content */ + language?: string; +} + +/** + * A human-readable summary of the resource. + * @see https://hl7.org/fhir/R4/narrative.html + */ +export interface Narrative extends Element { + /** generated | extensions | additional | empty */ + status: 'generated' | 'extensions' | 'additional' | 'empty'; + /** Limited xhtml content */ + div: string; +} + +/** + * DomainResource — a resource with narrative, extensions, and contained resources. + * @see https://hl7.org/fhir/R4/domainresource.html + */ +export interface DomainResource extends Resource { + /** Text summary of the resource, for human interpretation */ + text?: Narrative; + /** Contained, inline Resources */ + contained?: Resource[]; + /** Additional content defined by implementations */ + extension?: Extension[]; + /** Extensions that cannot be ignored */ + modifierExtension?: Extension[]; +} + +// ─── Reference ─────────────────────────────────────────────────────────────── + +/** + * A reference from one resource to another. + * @see https://hl7.org/fhir/R4/references.html#Reference + */ +export interface Reference extends Element { + /** Literal reference, Relative, internal or absolute URL */ + reference?: string; + /** Type the reference refers to (e.g. "Patient") */ + type?: string; + /** Logical reference, when literal reference is not known */ + identifier?: Identifier; + /** Text alternative for the resource */ + display?: string; +} + +// ─── Complex Data Types ────────────────────────────────────────────────────── + +/** + * An identifier intended for computation. + * @see https://hl7.org/fhir/R4/datatypes.html#Identifier + */ +export interface Identifier extends Element { + /** usual | official | temp | secondary | old (if known) */ + use?: 'usual' | 'official' | 'temp' | 'secondary' | 'old'; + /** Description of identifier */ + type?: CodeableConcept; + /** The namespace for the identifier value */ + system?: string; + /** The value that is unique */ + value?: string; + /** Time period when id is/was valid for use */ + period?: Period; + /** Organization that issued id (may be just text) */ + assigner?: Reference; +} + +/** + * A concept defined by a terminology system. + * @see https://hl7.org/fhir/R4/datatypes.html#Coding + */ +export interface Coding extends Element { + /** Identity of the terminology system */ + system?: string; + /** Version of the system */ + version?: string; + /** Symbol in syntax defined by the system */ + code?: string; + /** Representation defined by the system */ + display?: string; + /** If this coding was chosen directly by the user */ + userSelected?: boolean; +} + +/** + * A CodeableConcept represents a value that is usually supplied by + * providing a reference to one or more terminologies. + * @see https://hl7.org/fhir/R4/datatypes.html#CodeableConcept + */ +export interface CodeableConcept extends Element { + /** Code defined by a terminology system */ + coding?: Coding[]; + /** Plain text representation of the concept */ + text?: string; +} + +/** + * A time period defined by a start and end date/time. + * @see https://hl7.org/fhir/R4/datatypes.html#Period + */ +export interface Period extends Element { + /** Starting time with inclusive boundary */ + start?: string; + /** End time with inclusive boundary, if not ongoing */ + end?: string; +} + +/** + * A measured amount (or an amount that can potentially be measured). + * @see https://hl7.org/fhir/R4/datatypes.html#Quantity + */ +export interface Quantity extends Element { + /** Numerical value (with implicit precision) */ + value?: number; + /** < | <= | >= | > — how to understand the value */ + comparator?: '<' | '<=' | '>=' | '>'; + /** Unit representation */ + unit?: string; + /** System that defines coded unit form */ + system?: string; + /** Coded form of the unit */ + code?: string; +} + +/** + * A set of ordered Quantities defined by a low and high limit. + * @see https://hl7.org/fhir/R4/datatypes.html#Range + */ +export interface Range extends Element { + /** Low limit */ + low?: Quantity; + /** High limit */ + high?: Quantity; +} + +/** + * Content in a format defined elsewhere. + * @see https://hl7.org/fhir/R4/datatypes.html#Attachment + */ +export interface Attachment extends Element { + /** Mime type of the content, with charset etc. */ + contentType?: string; + /** Human language of the content (BCP-47) */ + language?: string; + /** Data inline, base64ed */ + data?: string; + /** Uri where the data can be found */ + url?: string; + /** Number of bytes of content (if url provided) */ + size?: number; + /** Hash of the data (sha-1, base64ed) */ + hash?: string; + /** Label to display in place of the data */ + title?: string; + /** Date attachment was first created */ + creation?: string; +} + +/** + * A name of a human with text, parts and usage information. + * @see https://hl7.org/fhir/R4/datatypes.html#HumanName + */ +export interface HumanName extends Element { + /** usual | official | temp | nickname | anonymous | old | maiden */ + use?: 'usual' | 'official' | 'temp' | 'nickname' | 'anonymous' | 'old' | 'maiden'; + /** Text representation of the full name */ + text?: string; + /** Family name (often called 'Surname') */ + family?: string; + /** Given names (not always 'first'). Includes middle names */ + given?: string[]; + /** Parts that come before the name */ + prefix?: string[]; + /** Parts that come after the name */ + suffix?: string[]; + /** Time period when name was/is in use */ + period?: Period; +} + +/** + * Details for all kinds of technology-mediated contact points. + * @see https://hl7.org/fhir/R4/datatypes.html#ContactPoint + */ +export interface ContactPoint extends Element { + /** phone | fax | email | pager | url | sms | other */ + system?: 'phone' | 'fax' | 'email' | 'pager' | 'url' | 'sms' | 'other'; + /** The actual contact point details */ + value?: string; + /** home | work | temp | old | mobile — purpose of this contact point */ + use?: 'home' | 'work' | 'temp' | 'old' | 'mobile'; + /** Specify preferred order of use (1 = highest) */ + rank?: number; + /** Time period when the contact point was/is in use */ + period?: Period; +} + +/** + * An address expressed using postal conventions. + * @see https://hl7.org/fhir/R4/datatypes.html#Address + */ +export interface Address extends Element { + /** home | work | temp | old | billing — purpose of this address */ + use?: 'home' | 'work' | 'temp' | 'old' | 'billing'; + /** postal | physical | both */ + type?: 'postal' | 'physical' | 'both'; + /** Text representation of the address */ + text?: string; + /** Street name, number, direction & P.O. Box etc. */ + line?: string[]; + /** Name of city, town etc. */ + city?: string; + /** District name (aka county) */ + district?: string; + /** Sub-unit of country (abbreviations ok) */ + state?: string; + /** Postal code for area */ + postalCode?: string; + /** Country (e.g. may be ISO 3166 2 or 3 letter code) */ + country?: string; + /** Time period when address was/is in use */ + period?: Period; +} + +/** + * A signature along with supporting context. + * @see https://hl7.org/fhir/R4/datatypes.html#Signature + */ +export interface Signature extends Element { + /** Indication of the reason the entity signed the object(s) */ + type: Coding[]; + /** When the signature was created */ + when: string; + /** Who signed */ + who: Reference; + /** The party represented */ + onBehalfOf?: Reference; + /** The technical format of the signed resources */ + targetFormat?: string; + /** The technical format of the signature */ + sigFormat?: string; + /** The actual signature content (XML DigSig. JWS, picture, etc.) */ + data?: string; +} diff --git a/app/src/types/fhirR4/bundle.ts b/app/src/types/fhirR4/bundle.ts new file mode 100644 index 0000000000..d993e30ae2 --- /dev/null +++ b/app/src/types/fhirR4/bundle.ts @@ -0,0 +1,173 @@ +/** + * FHIR R4 Bundle Resource + * A container for a collection of resources. + * + * @see https://hl7.org/fhir/R4/bundle.html + */ + +import { Identifier, Meta, Resource, Signature } from './baseTypes'; + +// ─── Value Sets / Enums ────────────────────────────────────────────────────── + +/** + * Indicates the purpose of a bundle — how it is intended to be used. + * @see https://hl7.org/fhir/R4/valueset-bundle-type.html + */ +export enum BundleType { + /** The bundle is a document */ + Document = 'document', + /** The bundle is a message */ + Message = 'message', + /** The bundle is a transaction */ + Transaction = 'transaction', + /** The bundle is a transaction response */ + TransactionResponse = 'transaction-response', + /** The bundle is a batch */ + Batch = 'batch', + /** The bundle is a batch response */ + BatchResponse = 'batch-response', + /** The bundle is a history list */ + History = 'history', + /** The results of a search */ + Searchset = 'searchset', + /** A collection of resources */ + Collection = 'collection', +} + +/** + * HTTP verbs used in Bundle.entry.request. + * @see https://hl7.org/fhir/R4/valueset-http-verb.html + */ +export enum HTTPVerb { + GET = 'GET', + HEAD = 'HEAD', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + PATCH = 'PATCH', +} + +/** + * Why an entry is in the result set (for searchset bundles). + * @see https://hl7.org/fhir/R4/valueset-search-entry-mode.html + */ +export enum SearchEntryMode { + /** This resource matched the search specification */ + Match = 'match', + /** This resource is returned because it is referred to from another resource in the search set */ + Include = 'include', + /** An OperationOutcome that provides additional information about the processing of a search */ + Outcome = 'outcome', +} + +// ─── Backbone Elements ─────────────────────────────────────────────────────── + +/** + * Links related to this Bundle. + * @see https://hl7.org/fhir/R4/bundle-definitions.html#Bundle.link + */ +export interface BundleLink { + /** See https://www.iana.org/assignments/link-relations — e.g. self, next, previous */ + relation: string; + /** Reference details for the link */ + url: string; +} + +/** + * Search-related information for a searchset bundle entry. + * @see https://hl7.org/fhir/R4/bundle-definitions.html#Bundle.entry.search + */ +export interface BundleEntrySearch { + /** match | include | outcome — why this is in the result set */ + mode?: SearchEntryMode | string; + /** Search ranking (between 0 and 1) */ + score?: number; +} + +/** + * Additional execution information (transaction/batch/history). + * @see https://hl7.org/fhir/R4/bundle-definitions.html#Bundle.entry.request + */ +export interface BundleEntryRequest { + /** GET | HEAD | POST | PUT | DELETE | PATCH */ + method: HTTPVerb | string; + /** URL for HTTP equivalent of this entry */ + url: string; + /** For managing cache currency */ + ifNoneMatch?: string; + /** For managing cache currency */ + ifModifiedSince?: string; + /** For managing update contention */ + ifMatch?: string; + /** For conditional creates */ + ifNoneExist?: string; +} + +/** + * Results of execution (transaction/batch/history). + * @see https://hl7.org/fhir/R4/bundle-definitions.html#Bundle.entry.response + */ +export interface BundleEntryResponse { + /** Status response code (text + optional HTTP code) */ + status: string; + /** The location (if the operation returns a location) */ + location?: string; + /** The Etag for the resource (if relevant) */ + etag?: string; + /** Server's date-time modified */ + lastModified?: string; + /** OperationOutcome with hints and warnings */ + outcome?: Resource; +} + +/** + * An entry in a bundle resource — will either contain a resource, or information about a request. + * @see https://hl7.org/fhir/R4/bundle-definitions.html#Bundle.entry + */ +export interface BundleEntry { + /** Links related to this entry */ + link?: BundleLink[]; + /** URI for resource (Absolute URL server address or URI for UUID/OID) */ + fullUrl?: string; + /** A resource in the bundle */ + resource: T; + /** Search related information */ + /** Additional execution information (transaction/batch/history) */ +} + +// ─── Bundle Resource ───────────────────────────────────────────────────────── + +/** + * A container for a collection of resources. + * + * @see https://hl7.org/fhir/R4/bundle.html + * + * @typeParam T - The type of resource contained in the bundle entries. + * Defaults to `Resource` for generic use. + */ +export interface Bundle { + /** Resource type discriminator */ + resourceType: string; + /** Logical id of this artifact */ + id?: string; + /** Metadata about the resource */ + meta?: Meta; + /** A set of rules under which this content was created */ + implicitRules?: string; + /** Language of the resource content */ + language?: string; + /** Persistent identifier for the bundle */ + identifier?: Identifier; + /** document | message | transaction | transaction-response | batch | batch-response | history | searchset | collection */ + type: BundleType | string; + /** When the bundle was assembled */ + timestamp?: string; + /** If search, the total number of matches */ + total?: number; + /** Links related to this Bundle */ + link?: BundleLink[]; + /** Entry in the bundle — will have a resource or information */ + entry?: Array>; + /** Digital Signature */ + signature?: Signature; +} diff --git a/app/src/types/fhirR4/bundleHistory1.fhir.json b/app/src/types/fhirR4/bundleHistory1.fhir.json new file mode 100644 index 0000000000..f5599c0497 --- /dev/null +++ b/app/src/types/fhirR4/bundleHistory1.fhir.json @@ -0,0 +1,125 @@ +{ + "resourceType": "Bundle", + "type": "history", + "timestamp": "2026-02-17T00:00:00Z", + "total": 2, + "entry": [ + { + "fullUrl": "https://example.org/fhir/DocumentReference/LG-12345/_history/1", + "resource": { + "resourceType": "DocumentReference", + "id": "LG-12345", + "meta": { + "versionId": "1" + }, + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + }, + "date": "2024-01-10T09:15:00Z", + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345" + } + } + ], + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345" + } + }, + "content": [ + { + "attachment": { + "url": "https://example.org/fhir/Binary/abcd", + "contentType": "application/pdf", + "title": "Lloyd George Record", + "creation": "2024-01-10T09:15:00Z", + "size": 120456 + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-DocumentReferenceContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-DocumentReferenceContentStability", + "code": "static" + } + ] + } + } + ] + } + ] + } + }, + { + "fullUrl": "https://example.org/fhir/DocumentReference/LG-12345/_history/2", + "resource": { + "resourceType": "DocumentReference", + "id": "LG-12345", + "meta": { + "versionId": "2" + }, + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + }, + "date": "2024-02-12T14:05:00Z", + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345" + } + } + ], + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345" + } + }, + "content": [ + { + "attachment": { + "url": "https://example.org/fhir/Binary/abcd", + "contentType": "application/pdf", + "title": "Lloyd George Record", + "creation": "2024-01-10T09:15:00Z", + "size": 120456 + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-DocumentReferenceContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-DocumentReferenceContentStability", + "code": "static" + } + ] + } + } + ] + } + ] + } + } + ] +} + diff --git a/app/src/types/fhirR4/bundleHistory2.fhir.json b/app/src/types/fhirR4/bundleHistory2.fhir.json new file mode 100644 index 0000000000..7e9b0c8d6a --- /dev/null +++ b/app/src/types/fhirR4/bundleHistory2.fhir.json @@ -0,0 +1,161 @@ +{ + "resourceType": "Bundle", + "type": "history", + "timestamp": "2026-02-26T10:29:57Z", + "total": 3, + "entry": [ + { + "resource": { + "id": "16521000000101~311bc253-1bb5-4d0c-ab21-0900133cfb14", + "resourceType": "DocumentReference", + "docStatus": "final", + "status": "current", + "type": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "16521000000101", + "display": "Lloyd George record folder" + } + ] + }, + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9730153817" + } + }, + "date": "2026-02-26T04:55:14.582406Z", + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "H81109" + } + } + ], + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "H81109" + } + }, + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "language": "en-GB", + "title": "1of1_Lloyd_George_Record_[Haley Glenda RUDKIN]_[9730153817]_[07-08-2023].pdf", + "creation": "2026-02-26" + } + } + ], + "meta": { + "versionId": "3" + } + } + }, + { + "resource": { + "id": "16521000000101~f9818e29-0421-4d14-91f5-eca2889f16f4", + "resourceType": "DocumentReference", + "docStatus": "deprecated", + "status": "current", + "type": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "16521000000101", + "display": "Lloyd George record folder" + } + ] + }, + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9730153817" + } + }, + "date": "2026-02-26T02:53:55.481331Z", + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "H81109" + } + } + ], + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "H81109" + } + }, + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "language": "en-GB", + "title": "1of1_Lloyd_George_Record_[Haley Glenda RUDKIN]_[9730153817]_[07-08-2023].pdf", + "creation": "2026-02-26" + } + } + ], + "meta": { + "versionId": "1" + } + } + }, + { + "resource": { + "id": "16521000000101~c8df691c-322b-4e7d-9dc0-bc1337ed535e", + "resourceType": "DocumentReference", + "docStatus": "deprecated", + "status": "current", + "type": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "16521000000101", + "display": "Lloyd George record folder" + } + ] + }, + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9730153817" + } + }, + "date": "2026-02-26T02:54:27.620982Z", + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "H81109" + } + } + ], + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "H81109" + } + }, + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "language": "en-GB", + "title": "1of1_Lloyd_George_Record_[Haley Glenda RUDKIN]_[9730153817]_[07-08-2023].pdf", + "creation": "2026-02-26" + } + } + ], + "meta": { + "versionId": "2" + } + } + } + ] +} \ No newline at end of file diff --git a/app/src/types/fhirR4/documentReference.ts b/app/src/types/fhirR4/documentReference.ts new file mode 100644 index 0000000000..0d5c180324 --- /dev/null +++ b/app/src/types/fhirR4/documentReference.ts @@ -0,0 +1,207 @@ +/** + * FHIR R4 DocumentReference Resource + * A reference to a document of any kind for any purpose. + * + * @see https://hl7.org/fhir/R4/documentreference.html + */ + +import { + Attachment, + CodeableConcept, + Coding, + DomainResource, + Element, + Extension, + Identifier, + Period, + Reference, +} from './baseTypes'; +import { + DocumentReferenceDocStatus, + DocumentReferenceStatus, + DocumentRelationshipType, +} from './valueSets'; +export { + DocumentReferenceDocStatus, + DocumentReferenceStatus, + DocumentRelationshipType, +} from './valueSets'; + +/** + * Relationships to other documents. + * @see https://hl7.org/fhir/R4/documentreference-definitions.html#DocumentReference.relatesTo + */ +export interface DocumentReferenceRelatesTo extends Element { + /** Additional content defined by implementations */ + extension?: Extension[]; + /** Extensions that cannot be ignored even if unrecognized */ + modifierExtension?: Extension[]; + /** replaces | transforms | signs | appends */ + code: DocumentRelationshipType | string; + /** Target of the relationship */ + target: Reference; +} + +/** + * Document referenced — the actual content of the document. + * @see https://hl7.org/fhir/R4/documentreference-definitions.html#DocumentReference.content + */ +export interface DocumentReferenceContent extends Element { + /** Additional content defined by implementations */ + extension?: Extension[]; + /** Extensions that cannot be ignored even if unrecognized */ + modifierExtension?: Extension[]; + /** Where to access the document */ + attachment: Attachment; + /** Format/content rules for the document */ + format?: Coding; +} + +/** + * Clinical context of the document. + * @see https://hl7.org/fhir/R4/documentreference-definitions.html#DocumentReference.context + */ +export interface DocumentReferenceContext extends Element { + /** Additional content defined by implementations */ + extension?: Extension[]; + /** Extensions that cannot be ignored even if unrecognized */ + modifierExtension?: Extension[]; + /** Context of the document content — Encounter or EpisodeOfCare */ + encounter?: Reference[]; + /** Main clinical acts documented (e.g. procedure codes) */ + event?: CodeableConcept[]; + /** Time of service that is being documented */ + period?: Period; + /** Kind of facility where patient was seen */ + facilityType?: CodeableConcept; + /** Additional details about where the content was created (e.g. clinical specialty) */ + practiceSetting?: CodeableConcept; + /** Patient demographics from source */ + sourcePatientInfo?: Reference; + /** Related identifiers or resources */ + related?: Reference[]; +} + +// ─── DocumentReference Resource ────────────────────────────────────────────── + +/** + * A reference to a document of any kind for any purpose. Provides metadata + * about the document so that the document can be discovered and managed. The + * scope of a document is any serially-produced media object with an identified + * MIME type, e.g., clinical notes, discharge summaries, x-rays, etc. + * + * @see https://hl7.org/fhir/R4/documentreference.html + */ +export interface FhirDocumentReference extends DomainResource { + /** Resource type discriminator */ + resourceType: 'DocumentReference'; + + /** + * Master Version Specific Identifier. + * Document identifier as assigned by the source of the document. + * This identifier is specific to this version of the document. + */ + masterIdentifier?: Identifier; + + /** + * Other identifiers for the document. + * May include accession numbers, provider-specific identifiers, etc. + */ + identifier?: Identifier[]; + + /** + * The status of this document reference. + * current | superseded | entered-in-error + */ + status: DocumentReferenceStatus | string; + + /** + * Status of the underlying document. + * preliminary | final | amended | entered-in-error + */ + docStatus?: DocumentReferenceDocStatus | string; + + /** + * Kind of document (LOINC if possible). + * Specifies the particular kind of document referenced. + */ + type?: CodeableConcept; + + /** + * Categorization of document. + * A categorization for the type of document referenced — helps for indexing + * and searching. This may be implied by or derived from the code specified + * in the DocumentReference.type. + */ + category?: CodeableConcept[]; + + /** + * Who/what is the subject of the document. + * Who or what the document is about. The document can be about a person + * (patient or healthcare practitioner), a device, or even a group of subjects. + * Reference(Patient | Practitioner | Group | Device) + */ + subject?: Reference; + + /** + * When this document reference was created. + * When the document reference was created. + */ + date?: string; + + /** + * Who and/or what authored the document. + * Identifies who is responsible for adding the information to the document. + * Reference(Practitioner | PractitionerRole | Organization | Device | Patient | RelatedPerson) + */ + author?: Reference[]; + + /** + * Who/what authenticated the document. + * Which person or organization authenticates that this document is valid. + * Reference(Practitioner | PractitionerRole | Organization) + */ + authenticator?: Reference; + + /** + * Organization which maintains the document. + * Identifies the organization or group who is responsible for ongoing + * maintenance of and access to the document. + * Reference(Organization) + */ + custodian?: Reference; + + /** + * Relationships to other documents. + * Relationships that this document has with other document references that already exist. + */ + relatesTo?: DocumentReferenceRelatesTo[]; + + /** + * Human-readable description of the source document. + */ + description?: string; + + /** + * Document security-tags. + * A set of Security-Tag codes specifying the level of privacy/security + * of the document. Note that DocumentReference.meta.security contains + * the security labels of the "reference" to the document, while + * DocumentReference.securityLabel contains the security labels of the + * document itself. + */ + securityLabel?: CodeableConcept[]; + + /** + * Document referenced. + * The document and format referenced. There may be multiple content elements, + * each with a different format. + */ + content: DocumentReferenceContent[]; + + /** + * Clinical context of document. + * The clinical context in which the document was prepared. + */ + context?: DocumentReferenceContext; +} diff --git a/app/src/types/fhirR4/fhir.test.ts b/app/src/types/fhirR4/fhir.test.ts new file mode 100644 index 0000000000..a4623b2e12 --- /dev/null +++ b/app/src/types/fhirR4/fhir.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from 'vitest'; +import { BundleType } from './bundle'; +import type { Bundle, BundleEntry } from './bundle'; +import type { FhirDocumentReference } from './documentReference'; +import bundleHistory1Json from './bundleHistory1.fhir.json'; +import bundleHistory2Json from './bundleHistory2.fhir.json'; + +const bundleHistory = bundleHistory1Json as unknown as Bundle; +const firstEntry = bundleHistory.entry?.[0]?.resource as FhirDocumentReference; +const secondEntry = bundleHistory.entry?.[1]?.resource as FhirDocumentReference; + +describe('FHIR Bundle history mapping', () => { + describe.each([ + ['bundleHistory1Json', bundleHistory1Json], + ['bundleHistory2Json', bundleHistory2Json], + ])('Generic Bundle model mapping — %s', (_, fixture) => { + const bundle = fixture as unknown as Bundle; + + describe('Bundle shape', () => { + it('has resourceType "Bundle"', () => { + expect(bundle.resourceType).toBe('Bundle'); + }); + + it('has a valid BundleType for type', () => { + expect(Object.values(BundleType)).toContain(bundle.type); + }); + + it('has a timestamp string', () => { + expect(typeof bundle.timestamp).toBe('string'); + expect(bundle.timestamp).toBeTruthy(); + }); + + it('has a numeric total', () => { + expect(typeof bundle.total).toBe('number'); + }); + + it('has an entry array', () => { + expect(Array.isArray(bundle.entry)).toBe(true); + }); + + it('total matches entry count', () => { + expect(bundle.entry?.length).toBe(bundle.total); + }); + }); + + describe('Bundle entry shape', () => { + it('every entry has a fullUrl string', () => { + bundle.entry?.forEach((e: BundleEntry) => { + if (typeof e.fullUrl !== 'undefined') { + expect(typeof e.fullUrl).toBe('string'); + expect(e.fullUrl).toBeTruthy(); + } else { + expect(e.fullUrl).toBeUndefined(); + } + }); + }); + + it('every entry has a resource object', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(e.resource).toBeDefined(); + expect(typeof e.resource).toBe('object'); + }); + }); + }); + + describe('DocumentReference shape', () => { + it('every resource has resourceType "DocumentReference"', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(e.resource.resourceType).toBe('DocumentReference'); + }); + }); + + it('every resource has an id string', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(typeof e.resource.id).toBe('string'); + expect(e.resource.id).toBeTruthy(); + }); + }); + + it('every resource has meta.versionId', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(e.resource.meta?.versionId).toBeDefined(); + }); + }); + + it('every resource has a date string', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(typeof e.resource.date).toBe('string'); + }); + }); + + it('every resource has subject with NHS number identifier', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(e.resource.subject?.identifier?.system).toBe( + 'https://fhir.nhs.uk/Id/nhs-number', + ); + expect(e.resource.subject?.identifier?.value).toBeTruthy(); + }); + }); + + it('every resource has at least one author with ODS identifier', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(e.resource.author?.length).toBeGreaterThan(0); + expect(e.resource.author?.[0]?.identifier?.value).toBeTruthy(); + }); + }); + + it('every resource has a custodian with ODS identifier', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(e.resource.custodian?.identifier?.value).toBeTruthy(); + }); + }); + + it('every resource has at least one content item', () => { + bundle.entry?.forEach((e: BundleEntry) => { + expect(e.resource.content.length).toBeGreaterThan(0); + }); + }); + + it('every content item has an attachment with url and contentType', () => { + bundle.entry?.forEach((e: BundleEntry) => { + e.resource.content.forEach((c) => { + if (typeof c.attachment.url === 'string') { + expect(c.attachment.url).toBeTruthy(); + } else { + expect(c.attachment.url).toBeUndefined(); + } + if (typeof c.attachment.contentType === 'string') { + expect(c.attachment.contentType).toBeTruthy(); + } else { + expect(c.attachment.contentType).toBeUndefined(); + } + }); + }); + }); + + it('every content item attachment has a positive size', () => { + bundle.entry?.forEach((e: BundleEntry) => { + e.resource.content.forEach((c) => { + if (typeof c.attachment.size === 'number') { + expect(c.attachment.size).toBeGreaterThan(0); + } else { + expect(c.attachment.size).toBeUndefined(); + } + }); + }); + }); + }); + }); + + describe('Bundle top-level fields', () => { + it('has resourceType "Bundle"', () => { + expect(bundleHistory.resourceType).toBe('Bundle'); + }); + + it('has type "history"', () => { + expect(bundleHistory.type).toBe(BundleType.History); + }); + + it('has timestamp', () => { + expect(bundleHistory.timestamp).toBe('2026-02-17T00:00:00Z'); + }); + + it('has total of 2', () => { + expect(bundleHistory.total).toBe(2); + }); + + it('has 2 entries', () => { + expect(bundleHistory.entry).toHaveLength(2); + }); + + it('satisfies Bundle type', () => { + const typed: Bundle = bundleHistory; + expect(typed).toBeDefined(); + }); + }); + + describe('Bundle entry fullUrls', () => { + it('first entry has expected fullUrl containing _history/1', () => { + expect(bundleHistory.entry?.[0].fullUrl).toContain('_history/1'); + }); + + it('second entry has expected fullUrl containing _history/2', () => { + expect(bundleHistory.entry?.[1].fullUrl).toContain('_history/2'); + }); + }); + + describe('DocumentReference — first entry (version 1)', () => { + it('has resourceType "DocumentReference"', () => { + expect(firstEntry.resourceType).toBe('DocumentReference'); + }); + + it('has id "LG-12345"', () => { + expect(firstEntry.id).toBe('LG-12345'); + }); + + it('has meta.versionId "1"', () => { + expect(firstEntry.meta?.versionId).toBe('1'); + }); + + it('has date "2024-01-10T09:15:00Z"', () => { + expect(firstEntry.date).toBe('2024-01-10T09:15:00Z'); + }); + + it('has subject NHS number "9999999999"', () => { + expect(firstEntry.subject?.identifier?.value).toBe('9999999999'); + }); + + it('has subject NHS number system', () => { + expect(firstEntry.subject?.identifier?.system).toBe( + 'https://fhir.nhs.uk/Id/nhs-number', + ); + }); + + it('has author ODS code "A12345"', () => { + expect(firstEntry.author?.[0]?.identifier?.value).toBe('A12345'); + }); + + it('has custodian ODS code "A12345"', () => { + expect(firstEntry.custodian?.identifier?.value).toBe('A12345'); + }); + + it('has one content item', () => { + expect(firstEntry.content).toHaveLength(1); + }); + + it('content attachment has contentType "application/pdf"', () => { + expect(firstEntry.content[0].attachment.contentType).toBe('application/pdf'); + }); + + it('content attachment has title "Lloyd George Record"', () => { + expect(firstEntry.content[0].attachment.title).toBe('Lloyd George Record'); + }); + + it('content attachment has correct size', () => { + expect(firstEntry.content[0].attachment.size).toBe(120456); + }); + + it('content attachment has url', () => { + expect(firstEntry.content[0].attachment.url).toBe( + 'https://example.org/fhir/Binary/abcd', + ); + }); + + it('content attachment has creation date', () => { + expect(firstEntry.content[0].attachment.creation).toBe('2024-01-10T09:15:00Z'); + }); + + it('content format has NRL CodeSystem system', () => { + expect(firstEntry.content[0].format?.system).toBe( + 'https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode', + ); + }); + + it('content extension has stability code "static"', () => { + const extension = firstEntry.content[0].extension?.[0]; + expect(extension?.valueCodeableConcept?.coding?.[0].code).toBe('static'); + }); + }); + + describe('DocumentReference — second entry (version 2)', () => { + it('has resourceType "DocumentReference"', () => { + expect(secondEntry.resourceType).toBe('DocumentReference'); + }); + + it('has id "LG-12345"', () => { + expect(secondEntry.id).toBe('LG-12345'); + }); + + it('has meta.versionId "2"', () => { + expect(secondEntry.meta?.versionId).toBe('2'); + }); + + it('has date "2024-02-12T14:05:00Z"', () => { + expect(secondEntry.date).toBe('2024-02-12T14:05:00Z'); + }); + + it('has same subject NHS number as version 1', () => { + expect(secondEntry.subject?.identifier?.value).toBe('9999999999'); + }); + + it('has author ODS code "A12345"', () => { + expect(secondEntry.author?.[0]?.identifier?.value).toBe('A12345'); + }); + + it('has custodian ODS code "A12345"', () => { + expect(secondEntry.custodian?.identifier?.value).toBe('A12345'); + }); + + it('content attachment has same size as version 1 (content unchanged)', () => { + expect(secondEntry.content[0].attachment.size).toBe(120456); + }); + + it('content extension has stability code "static"', () => { + const extension = secondEntry.content[0].extension?.[0]; + expect(extension?.valueCodeableConcept?.coding?.[0].code).toBe('static'); + }); + }); + + describe('Version history consistency', () => { + it('both versions share the same document id', () => { + expect(firstEntry.id).toBe(secondEntry.id); + }); + + it('version numbers are sequential', () => { + expect(Number(firstEntry.meta?.versionId)).toBe(1); + expect(Number(secondEntry.meta?.versionId)).toBe(2); + }); + + it('second entry date is later than first entry date', () => { + const firstDate = new Date(firstEntry.date ?? ''); + const secondDate = new Date(secondEntry.date ?? ''); + expect(secondDate.getTime()).toBeGreaterThan(firstDate.getTime()); + }); + + it('both versions reference the same attachment URL', () => { + expect(firstEntry.content[0].attachment.url).toBe( + secondEntry.content[0].attachment.url, + ); + }); + }); +}); diff --git a/app/src/types/fhirR4/valueSets.ts b/app/src/types/fhirR4/valueSets.ts new file mode 100644 index 0000000000..e6639aa450 --- /dev/null +++ b/app/src/types/fhirR4/valueSets.ts @@ -0,0 +1,50 @@ +/** + * FHIR R4 Value Sets for DocumentReference + * + * Enumerations derived from FHIR R4 value sets used by the DocumentReference resource. + * + * @see https://hl7.org/fhir/R4/documentreference.html + */ + +/** + * The status of the document reference. + * @see https://hl7.org/fhir/R4/valueset-document-reference-status.html + */ +export enum DocumentReferenceStatus { + /** This is the current reference for this document */ + Current = 'current', + /** This reference has been superseded by another reference */ + Superseded = 'superseded', + /** This reference was created in error */ + EnteredInError = 'entered-in-error', +} + +/** + * Status of the underlying document. + * @see https://hl7.org/fhir/R4/valueset-composition-status.html + */ +export enum DocumentReferenceDocStatus { + /** This is a preliminary composition or document */ + Preliminary = 'preliminary', + /** This version of the composition is complete */ + Final = 'final', + /** The composition content has been amended */ + Amended = 'amended', + /** The composition or document was originally created/issued in error */ + EnteredInError = 'entered-in-error', +} + +/** + * The type of relationship between documents. + * @see https://hl7.org/fhir/R4/valueset-document-relationship-type.html + */ +export enum DocumentRelationshipType { + /** This document logically replaces or supersedes the target document */ + Replaces = 'replaces', + /** This document was generated by transforming the target document */ + Transforms = 'transforms', + /** This document is a signature of the target document */ + Signs = 'signs', + /** This document adds additional information to the target document */ + Appends = 'appends', +} diff --git a/app/src/types/generic/featureFlags.ts b/app/src/types/generic/featureFlags.ts index e6f6f0f652..22aee5d74b 100644 --- a/app/src/types/generic/featureFlags.ts +++ b/app/src/types/generic/featureFlags.ts @@ -6,6 +6,7 @@ export type FeatureFlags = { uploadDocumentIteration3Enabled?: boolean; documentCorrectEnabled?: boolean; userRestrictionEnabled?: boolean; + versionHistoryEnabled?: boolean; }; export const defaultFeatureFlags: FeatureFlags = { @@ -16,4 +17,5 @@ export const defaultFeatureFlags: FeatureFlags = { uploadDocumentIteration3Enabled: false, documentCorrectEnabled: false, userRestrictionEnabled: false, + versionHistoryEnabled: false, }; diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index 8c8b8454e6..4a07d5cf18 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -74,6 +74,12 @@ 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_VERSION_HISTORY = '/patient/documents/version-history', + + DOCUMENT_VIEW_VERSION_HISTORY = '/patient/documents/version-history-view', + DOCUMENT_VERSION_HISTORY = '/patient/documents/version-history', + DOCUMENT_VIEW = '/patient/documents/view', DOCUMENT_DELETE = '/patient/documents/delete', DOCUMENT_DELETE_CONFIRMATION = '/patient/documents/delete/confirmation',