From b5b27a63758940727c7790db1b611b1da8912ccd Mon Sep 17 00:00:00 2001 From: Lillie Dae Date: Fri, 20 Feb 2026 14:29:08 +0000 Subject: [PATCH 1/4] [PRMP-1409] feat: add Document Version History feature - Implemented DocumentVersionHistoryPage to display version history of documents. - Created Timeline component to visualize document versions with active/inactive states. - Added getDocumentVersionHistory function to fetch version history from API. - Introduced mock data for testing document version history responses. - Developed styles for TestToggle component to enhance UI. - Added unit tests for getDocumentVersionHistoryResponse to ensure correct API interaction and response structure. --- app/main.html | 2 + app/package.json | 1 - app/public/nhsapp-5.0.3.min.css | 1 + .../DocumentSelectOrderStage.test.tsx | 8 +- .../DocumentSearchResults.tsx | 19 +- .../documentView/DocumentView.tsx | 90 ++++--- .../components/blocks/testPanel/TestPanel.tsx | 9 +- .../blocks/testPanel/TestToggle.tsx | 21 -- .../components/blocks/testPanel/Toggle.scss | 71 +++++ .../components/blocks/testPanel/Toggle.tsx | 28 ++ .../components/generic/timeline/Timeline.tsx | 97 +++++++ app/src/config/lloydGeorgeConfig.json | 4 +- .../getDocumentVersionHistory.test.ts | 141 ++++++++++ .../requests/getDocumentVersionHistory.ts | 51 ++++ app/src/helpers/test/getMockVersionHistory.ts | 43 ++++ app/src/helpers/utils/documentType.ts | 242 ++++++++++++++++-- app/src/helpers/utils/formatDate.ts | 14 + .../DocumentVersionHistoryPage.tsx | 171 +++++++++++++ app/src/types/blocks/lloydGeorgeActions.ts | 97 +++++-- app/src/types/generic/fhir.ts | 11 + app/src/types/generic/routes.ts | 2 + 21 files changed, 1026 insertions(+), 97 deletions(-) create mode 100644 app/public/nhsapp-5.0.3.min.css delete mode 100644 app/src/components/blocks/testPanel/TestToggle.tsx create mode 100644 app/src/components/blocks/testPanel/Toggle.scss create mode 100644 app/src/components/blocks/testPanel/Toggle.tsx create mode 100644 app/src/components/generic/timeline/Timeline.tsx create mode 100644 app/src/helpers/requests/getDocumentVersionHistory.test.ts create mode 100644 app/src/helpers/requests/getDocumentVersionHistory.ts create mode 100644 app/src/helpers/test/getMockVersionHistory.ts create mode 100644 app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx create mode 100644 app/src/types/generic/fhir.ts diff --git a/app/main.html b/app/main.html index b6f9a302ce..f45a5f122a 100644 --- a/app/main.html +++ b/app/main.html @@ -49,6 +49,8 @@ + + diff --git a/app/package.json b/app/package.json index 6f73800926..253bdeb190 100644 --- a/app/package.json +++ b/app/package.json @@ -77,7 +77,6 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/react-toggle": "^4.0.5", "@types/sinon": "^21.0.0", "@types/uuid": "^11.0.0", "@types/validator": "^13.15.10", diff --git a/app/public/nhsapp-5.0.3.min.css b/app/public/nhsapp-5.0.3.min.css new file mode 100644 index 0000000000..f482ed553c --- /dev/null +++ b/app/public/nhsapp-5.0.3.min.css @@ -0,0 +1 @@ +.nhsapp-icon{fill:#005eb8}.nhsapp-icon--unread-indicator{fill:#d5281b;stroke:#fff}.nhsapp-icon--black{fill:#212b32}.nhsapp-icon--32{height:32px;width:32px}@media (max-width:40.0525em){.nhsapp-icon--32{height:24px;width:24px}}.nhsapp-badge{display:inline-block;background-color:#d5281b;border-radius:4px;color:#fff;font-weight:700;padding:0 8px;margin:0}.nhsapp-badge{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-badge{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-badge{font-size:13pt;line-height:1.25}}@media (min-width:40.0625em){.nhsapp-badge{padding:0 12px}}.nhsapp-badge-small{position:relative;display:inline-flex;align-items:baseline}.nhsapp-badge-small__indicator{position:relative;width:8px;height:8px;margin-right:8px;border-radius:4px;bottom:calc(.5 * (.7em - 8px));background-color:#d5281b}@media (min-width:40.0625em){.nhsapp-badge-small__indicator{position:relative;width:12px;height:12px;margin-right:12px;border-radius:6px;bottom:calc(.5 * (.7em - 12px))}}.nhsapp-badge-small--absolute .nhsapp-badge-small__indicator{position:absolute;left:-16px}@media (min-width:40.0625em){.nhsapp-badge-small--absolute .nhsapp-badge-small__indicator{left:-24px}}.nhsapp-button,.nhsapp-button.nhsuk-button--secondary::before,.nhsapp-button.nhsuk-button--secondary:active{border-radius:8px}.nhsapp-button.nhsuk-button--secondary-solid:not(:focus)::after,.nhsapp-button.nhsuk-button--secondary:not(:focus)::after{border-radius:6px!important}.nhsapp-card{background-color:#fff;border:2px solid #d8dde0;border-radius:8px;position:relative;padding:0}.nhsapp-card{margin-bottom:32px}@media (min-width:40.0625em){.nhsapp-card{margin-bottom:40px}}.nhsapp-card__title{font-weight:700;margin-bottom:0}.nhsapp-card__title{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-card__title{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-card__title{font-size:13pt;line-height:1.25}}.nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(0,94,184,.7);flex:none;height:24px;margin-right:-8px;width:24px}@media (min-width:40.0625em){.nhsapp-card .nhsapp-icon--chevron-right{height:28px;margin-right:-10px;width:28px}}.nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#7c2855}.nhsapp-card__container{display:flex;align-items:center;gap:12px;margin:0;padding:16px}@media (min-width:40.0625em){.nhsapp-card__container{gap:16px;margin:0;padding:24px}}.nhsapp-card__content{flex-grow:1}.nhsapp-card__content :last-child{margin-bottom:0}.nhsapp-card__link{font-weight:700;text-decoration:none}.nhsapp-card__link{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-card__link{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-card__link{font-size:13pt;line-height:1.25}}.nhsapp-card__link:hover{text-decoration:underline}.nhsapp-card__link::after{bottom:0;content:"";display:block;left:0;position:absolute;right:0;top:0}.nhsapp-card__description{color:#4c6272;margin:0;margin-top:4px}.nhsapp-card__description{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-card__description{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-card__description{font-size:13pt;line-height:1.25}}@media (min-width:40.0625em){.nhsapp-card__description{margin-top:8px}}.nhsapp-card__below :last-child{margin-bottom:0}.nhsapp-card__footer{border-top:1px solid #d8dde0;margin:0 16px;padding:16px 0}@media (min-width:40.0625em){.nhsapp-card__footer{margin:0 24px;padding:24px 0}}.nhsapp-card__footer :last-child{margin-bottom:0}.nhsapp-cards{list-style:none;padding:0}.nhsapp-cards{margin-bottom:32px}@media (min-width:40.0625em){.nhsapp-cards{margin-bottom:40px}}.nhsapp-cards .nhsapp-card{margin-bottom:16px}@media (min-width:40.0625em){.nhsapp-cards .nhsapp-card{margin-bottom:24px}}.nhsapp-cards .nhsapp-card:last-of-type{margin-bottom:0}.nhsapp-cards--stacked{margin-bottom:32px}@media (min-width:40.0625em){.nhsapp-cards--stacked{margin-bottom:40px}}.nhsapp-cards--stacked .nhsapp-card{border-bottom:0;border-radius:0;border-top:0;margin-bottom:0}.nhsapp-cards--stacked .nhsapp-card .nhsapp-card__container{border-bottom:1px solid #d8dde0}.nhsapp-cards--stacked .nhsapp-card:first-of-type{border-radius:8px 8px 0 0;border-top:2px solid #d8dde0}.nhsapp-cards--stacked .nhsapp-card:last-of-type{border-bottom:2px solid #d8dde0;border-radius:0 0 8px 8px}.nhsapp-cards--stacked .nhsapp-card:last-of-type .nhsapp-card__container{border-bottom:0}.nhsapp-cards--stacked .nhsapp-card:only-of-type{border-radius:8px;border-top:2px solid #d8dde0;border-bottom:2px solid #d8dde0}.nhsapp-card--secondary,.nhsapp-cards--secondary .nhsapp-card{background:0 0}.nhsapp-cards__heading{padding-top:0}.nhsapp-cards__heading{font-size:19px;font-size:1.1875rem;line-height:1.42105}@media (min-width:40.0625em){.nhsapp-cards__heading{font-size:22px;font-size:1.375rem;line-height:1.36364}}@media print{.nhsapp-cards__heading{font-size:15pt;line-height:1.25}}.nhsapp-cards__heading{margin-bottom:8px}@media (min-width:40.0625em){.nhsapp-cards__heading{margin-bottom:16px}}.nhsapp-cards__heading+.nhsapp-cards__description{margin-top:-8px}@media (min-width:40.0625em){.nhsapp-cards__heading+.nhsapp-cards__description{margin-top:-16px}}.nhsapp-cards__description{color:#4c6272;margin-bottom:12px}@media (min-width:40.0625em){.nhsapp-cards__description{margin-bottom:16px}}.nhsapp-card--pale-aqua-green,.nhsapp-cards--pale-aqua-green .nhsapp-card{background:#c9e3e0;border-color:#c9e3e0;color:#1e403d}.nhsapp-card--pale-aqua-green:first-of-type,.nhsapp-card--pale-aqua-green:last-of-type,.nhsapp-cards--pale-aqua-green .nhsapp-card:first-of-type,.nhsapp-cards--pale-aqua-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-aqua-green .nhsapp-card__container,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__container{border-color:rgba(30,64,61,.2)}.nhsapp-card--pale-aqua-green .nhsapp-card__link,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link{color:#1e403d}.nhsapp-card--pale-aqua-green .nhsapp-card__link:hover,.nhsapp-card--pale-aqua-green .nhsapp-card__link:hover:visited,.nhsapp-card--pale-aqua-green .nhsapp-card__link:visited,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:visited{color:#1e403d}.nhsapp-card--pale-aqua-green .nhsapp-card__link:focus,.nhsapp-card--pale-aqua-green .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-aqua-green .nhsapp-icon--chevron-right,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(30,64,61,.7)}.nhsapp-card--pale-aqua-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-aqua-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#1e403d}.nhsapp-card--dark-aqua-green,.nhsapp-cards--dark-aqua-green .nhsapp-card{background:#1e403d;border-color:#1e403d;color:#fff}.nhsapp-card--dark-aqua-green:first-of-type,.nhsapp-card--dark-aqua-green:last-of-type,.nhsapp-cards--dark-aqua-green .nhsapp-card:first-of-type,.nhsapp-cards--dark-aqua-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-aqua-green .nhsapp-card__container,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-aqua-green .nhsapp-card__link,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-aqua-green .nhsapp-card__link:hover,.nhsapp-card--dark-aqua-green .nhsapp-card__link:hover:visited,.nhsapp-card--dark-aqua-green .nhsapp-card__link:visited,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-aqua-green .nhsapp-card__link:focus,.nhsapp-card--dark-aqua-green .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-aqua-green .nhsapp-icon--chevron-right,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-aqua-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-aqua-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-blue,.nhsapp-cards--pale-blue .nhsapp-card{background:#ccdff1;border-color:#ccdff1;color:#00386e}.nhsapp-card--pale-blue:first-of-type,.nhsapp-card--pale-blue:last-of-type,.nhsapp-cards--pale-blue .nhsapp-card:first-of-type,.nhsapp-cards--pale-blue .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-blue .nhsapp-card__container,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__container{border-color:rgba(0,56,110,.2)}.nhsapp-card--pale-blue .nhsapp-card__link,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link{color:#00386e}.nhsapp-card--pale-blue .nhsapp-card__link:hover,.nhsapp-card--pale-blue .nhsapp-card__link:hover:visited,.nhsapp-card--pale-blue .nhsapp-card__link:visited,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:visited{color:#00386e}.nhsapp-card--pale-blue .nhsapp-card__link:focus,.nhsapp-card--pale-blue .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-blue .nhsapp-icon--chevron-right,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(0,56,110,.7)}.nhsapp-card--pale-blue:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-blue .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#00386e}.nhsapp-card--dark-blue,.nhsapp-cards--dark-blue .nhsapp-card{background:#00386e;border-color:#00386e;color:#fff}.nhsapp-card--dark-blue:first-of-type,.nhsapp-card--dark-blue:last-of-type,.nhsapp-cards--dark-blue .nhsapp-card:first-of-type,.nhsapp-cards--dark-blue .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-blue .nhsapp-card__container,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-blue .nhsapp-card__link,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-blue .nhsapp-card__link:hover,.nhsapp-card--dark-blue .nhsapp-card__link:hover:visited,.nhsapp-card--dark-blue .nhsapp-card__link:visited,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-blue .nhsapp-card__link:focus,.nhsapp-card--dark-blue .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-blue .nhsapp-icon--chevron-right,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-blue:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-blue .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-green,.nhsapp-cards--pale-green .nhsapp-card{background:#cce5d8;border-color:#cce5d8;color:#004c23}.nhsapp-card--pale-green:first-of-type,.nhsapp-card--pale-green:last-of-type,.nhsapp-cards--pale-green .nhsapp-card:first-of-type,.nhsapp-cards--pale-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-green .nhsapp-card__container,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__container{border-color:rgba(0,76,35,.2)}.nhsapp-card--pale-green .nhsapp-card__link,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link{color:#004c23}.nhsapp-card--pale-green .nhsapp-card__link:hover,.nhsapp-card--pale-green .nhsapp-card__link:hover:visited,.nhsapp-card--pale-green .nhsapp-card__link:visited,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:visited{color:#004c23}.nhsapp-card--pale-green .nhsapp-card__link:focus,.nhsapp-card--pale-green .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-green .nhsapp-icon--chevron-right,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(0,76,35,.7)}.nhsapp-card--pale-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#004c23}.nhsapp-card--dark-green,.nhsapp-cards--dark-green .nhsapp-card{background:#004c23;border-color:#004c23;color:#fff}.nhsapp-card--dark-green:first-of-type,.nhsapp-card--dark-green:last-of-type,.nhsapp-cards--dark-green .nhsapp-card:first-of-type,.nhsapp-cards--dark-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-green .nhsapp-card__container,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-green .nhsapp-card__link,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-green .nhsapp-card__link:hover,.nhsapp-card--dark-green .nhsapp-card__link:hover:visited,.nhsapp-card--dark-green .nhsapp-card__link:visited,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-green .nhsapp-card__link:focus,.nhsapp-card--dark-green .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-green .nhsapp-icon--chevron-right,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-orange,.nhsapp-cards--pale-orange .nhsapp-card{background:#fbe8cc;border-color:#fbe8cc;color:#5f3800}.nhsapp-card--pale-orange:first-of-type,.nhsapp-card--pale-orange:last-of-type,.nhsapp-cards--pale-orange .nhsapp-card:first-of-type,.nhsapp-cards--pale-orange .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-orange .nhsapp-card__container,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__container{border-color:rgba(95,56,0,.2)}.nhsapp-card--pale-orange .nhsapp-card__link,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link{color:#5f3800}.nhsapp-card--pale-orange .nhsapp-card__link:hover,.nhsapp-card--pale-orange .nhsapp-card__link:hover:visited,.nhsapp-card--pale-orange .nhsapp-card__link:visited,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:visited{color:#5f3800}.nhsapp-card--pale-orange .nhsapp-card__link:focus,.nhsapp-card--pale-orange .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-orange .nhsapp-icon--chevron-right,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(95,56,0,.7)}.nhsapp-card--pale-orange:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-orange .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#5f3800}.nhsapp-card--dark-orange,.nhsapp-cards--dark-orange .nhsapp-card{background:#5f3800;border-color:#5f3800;color:#fff}.nhsapp-card--dark-orange:first-of-type,.nhsapp-card--dark-orange:last-of-type,.nhsapp-cards--dark-orange .nhsapp-card:first-of-type,.nhsapp-cards--dark-orange .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-orange .nhsapp-card__container,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-orange .nhsapp-card__link,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-orange .nhsapp-card__link:hover,.nhsapp-card--dark-orange .nhsapp-card__link:hover:visited,.nhsapp-card--dark-orange .nhsapp-card__link:visited,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-orange .nhsapp-card__link:focus,.nhsapp-card--dark-orange .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-orange .nhsapp-icon--chevron-right,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-orange:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-orange .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-pink,.nhsapp-cards--pale-pink .nhsapp-card{background:#efd3e3;border-color:#efd3e3;color:#681645}.nhsapp-card--pale-pink:first-of-type,.nhsapp-card--pale-pink:last-of-type,.nhsapp-cards--pale-pink .nhsapp-card:first-of-type,.nhsapp-cards--pale-pink .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-pink .nhsapp-card__container,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__container{border-color:rgba(104,22,69,.2)}.nhsapp-card--pale-pink .nhsapp-card__link,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link{color:#681645}.nhsapp-card--pale-pink .nhsapp-card__link:hover,.nhsapp-card--pale-pink .nhsapp-card__link:hover:visited,.nhsapp-card--pale-pink .nhsapp-card__link:visited,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:visited{color:#681645}.nhsapp-card--pale-pink .nhsapp-card__link:focus,.nhsapp-card--pale-pink .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-pink .nhsapp-icon--chevron-right,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(104,22,69,.7)}.nhsapp-card--pale-pink:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-pink .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#681645}.nhsapp-card--dark-pink,.nhsapp-cards--dark-pink .nhsapp-card{background:#681645;border-color:#681645;color:#fff}.nhsapp-card--dark-pink:first-of-type,.nhsapp-card--dark-pink:last-of-type,.nhsapp-cards--dark-pink .nhsapp-card:first-of-type,.nhsapp-cards--dark-pink .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-pink .nhsapp-card__container,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-pink .nhsapp-card__link,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-pink .nhsapp-card__link:hover,.nhsapp-card--dark-pink .nhsapp-card__link:hover:visited,.nhsapp-card--dark-pink .nhsapp-card__link:visited,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-pink .nhsapp-card__link:focus,.nhsapp-card--dark-pink .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-pink .nhsapp-icon--chevron-right,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-pink:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-pink .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-purple,.nhsapp-cards--pale-purple .nhsapp-card{background:#ded6e8;border-color:#ded6e8;color:#402463}.nhsapp-card--pale-purple:first-of-type,.nhsapp-card--pale-purple:last-of-type,.nhsapp-cards--pale-purple .nhsapp-card:first-of-type,.nhsapp-cards--pale-purple .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-purple .nhsapp-card__container,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__container{border-color:rgba(64,36,99,.2)}.nhsapp-card--pale-purple .nhsapp-card__link,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link{color:#402463}.nhsapp-card--pale-purple .nhsapp-card__link:hover,.nhsapp-card--pale-purple .nhsapp-card__link:hover:visited,.nhsapp-card--pale-purple .nhsapp-card__link:visited,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:visited{color:#402463}.nhsapp-card--pale-purple .nhsapp-card__link:focus,.nhsapp-card--pale-purple .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-purple .nhsapp-icon--chevron-right,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(64,36,99,.7)}.nhsapp-card--pale-purple:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-purple .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#402463}.nhsapp-card--dark-purple,.nhsapp-cards--dark-purple .nhsapp-card{background:#402463;border-color:#402463;color:#fff}.nhsapp-card--dark-purple:first-of-type,.nhsapp-card--dark-purple:last-of-type,.nhsapp-cards--dark-purple .nhsapp-card:first-of-type,.nhsapp-cards--dark-purple .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-purple .nhsapp-card__container,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-purple .nhsapp-card__link,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-purple .nhsapp-card__link:hover,.nhsapp-card--dark-purple .nhsapp-card__link:hover:visited,.nhsapp-card--dark-purple .nhsapp-card__link:visited,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-purple .nhsapp-card__link:focus,.nhsapp-card--dark-purple .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-purple .nhsapp-icon--chevron-right,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-purple:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-purple .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-red,.nhsapp-cards--pale-red .nhsapp-card{background:#f7d4d1;border-color:#f7d4d1;color:#801810}.nhsapp-card--pale-red:first-of-type,.nhsapp-card--pale-red:last-of-type,.nhsapp-cards--pale-red .nhsapp-card:first-of-type,.nhsapp-cards--pale-red .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-red .nhsapp-card__container,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__container{border-color:rgba(128,24,16,.2)}.nhsapp-card--pale-red .nhsapp-card__link,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link{color:#801810}.nhsapp-card--pale-red .nhsapp-card__link:hover,.nhsapp-card--pale-red .nhsapp-card__link:hover:visited,.nhsapp-card--pale-red .nhsapp-card__link:visited,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:visited{color:#801810}.nhsapp-card--pale-red .nhsapp-card__link:focus,.nhsapp-card--pale-red .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-red .nhsapp-icon--chevron-right,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(128,24,16,.7)}.nhsapp-card--pale-red:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-red .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#801810}.nhsapp-card--dark-red,.nhsapp-cards--dark-red .nhsapp-card{background:#801810;border-color:#801810;color:#fff}.nhsapp-card--dark-red:first-of-type,.nhsapp-card--dark-red:last-of-type,.nhsapp-cards--dark-red .nhsapp-card:first-of-type,.nhsapp-cards--dark-red .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-red .nhsapp-card__container,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-red .nhsapp-card__link,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-red .nhsapp-card__link:hover,.nhsapp-card--dark-red .nhsapp-card__link:hover:visited,.nhsapp-card--dark-red .nhsapp-card__link:visited,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-red .nhsapp-card__link:focus,.nhsapp-card--dark-red .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-red .nhsapp-icon--chevron-right,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-red:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-red .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-yellow,.nhsapp-cards--pale-yellow .nhsapp-card{background:#fff7b1;border-color:#fff7b1;color:#4c4612}.nhsapp-card--pale-yellow:first-of-type,.nhsapp-card--pale-yellow:last-of-type,.nhsapp-cards--pale-yellow .nhsapp-card:first-of-type,.nhsapp-cards--pale-yellow .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-yellow .nhsapp-card__container,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__container{border-color:rgba(76,70,18,.2)}.nhsapp-card--pale-yellow .nhsapp-card__link,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link{color:#4c4612}.nhsapp-card--pale-yellow .nhsapp-card__link:hover,.nhsapp-card--pale-yellow .nhsapp-card__link:hover:visited,.nhsapp-card--pale-yellow .nhsapp-card__link:visited,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:visited{color:#4c4612}.nhsapp-card--pale-yellow .nhsapp-card__link:focus,.nhsapp-card--pale-yellow .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-yellow .nhsapp-icon--chevron-right,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(76,70,18,.7)}.nhsapp-card--pale-yellow:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-yellow .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#4c4612}.nhsapp-card--dark-yellow,.nhsapp-cards--dark-yellow .nhsapp-card{background:#4c4612;border-color:#4c4612;color:#fff}.nhsapp-card--dark-yellow:first-of-type,.nhsapp-card--dark-yellow:last-of-type,.nhsapp-cards--dark-yellow .nhsapp-card:first-of-type,.nhsapp-cards--dark-yellow .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-yellow .nhsapp-card__container,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-yellow .nhsapp-card__link,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-yellow .nhsapp-card__link:hover,.nhsapp-card--dark-yellow .nhsapp-card__link:hover:visited,.nhsapp-card--dark-yellow .nhsapp-card__link:visited,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-yellow .nhsapp-card__link:focus,.nhsapp-card--dark-yellow .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-yellow .nhsapp-icon--chevron-right,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-yellow:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-yellow .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--with-media .nhsapp-card__img{border-radius:8px 8px 0 0;display:block;max-width:100%}.nhsapp-card--with-media .nhsapp-card__container{padding:24px}@media (min-width:40.0625em){.nhsapp-card--with-media .nhsapp-card__container{padding:32px}}@media (min-width:48.0625em){.nhsapp-card--with-media{display:flex}.nhsapp-card--with-media .nhsapp-card__media{display:flex;flex:2 0}.nhsapp-card--with-media .nhsapp-card__img{border-radius:8px 0 0 8px;flex:none}.nhsapp-card--with-media .nhsapp-card__container{flex:2 0;gap:32px}}@media (min-width:61.875em){.nhsapp-card--with-media .nhsapp-card__media{flex-grow:2}.nhsapp-card--with-media .nhsapp-card__container{flex-grow:3}}.nhsapp-tag{background-color:#ccdff1;border:1px solid transparent;border-radius:2px;color:#00386e;display:inline-block;padding:3px 9px}.nhsapp-tag{font-weight:400}.nhsapp-tag{font-size:14px;font-size:.875rem;line-height:1.25}@media (min-width:40.0625em){.nhsapp-tag{font-size:16px;font-size:1rem;line-height:1.25}}@media print{.nhsapp-tag{font-size:12pt;line-height:1.25}}@media (min-width:40.0625em){.nhsapp-tag{line-height:1.4285em}}.nhsapp-tag--white{background-color:#fff;border-color:#d8dde0;color:#212b32}.nhsapp-tag--grey{background-color:#d8dde0;color:#212b32}.nhsapp-tag--green{background-color:#cce5d8;color:#004c23}.nhsapp-tag--aqua-green{background-color:#c9e3e0;color:#1e403d}.nhsapp-tag--blue{background-color:#ccdff1;color:#00386e}.nhsapp-tag--purple{background-color:#ded6e8;color:#402463}.nhsapp-tag--pink{background-color:#efd3e3;color:#681645}.nhsapp-tag--red{background-color:#f7d4d1;color:#801810}.nhsapp-tag--orange{background-color:#fbe8cc;color:#5f3800}.nhsapp-tag--yellow{background-color:#fff7b1;color:#4c4612}.nhsapp-timeline{list-style:none;padding:0}.nhsapp-timeline{margin-bottom:24px}@media (min-width:40.0625em){.nhsapp-timeline{margin-bottom:32px}}.nhsapp-timeline{padding-top:8px}@media (min-width:40.0625em){.nhsapp-timeline{padding-top:8px}}.nhsapp-timeline__item{display:flex;margin-bottom:0;margin-left:12px;margin-top:-6px;position:relative}.nhsapp-timeline__item{padding-bottom:24px}@media (min-width:40.0625em){.nhsapp-timeline__item{padding-bottom:32px}}.nhsapp-timeline__item:last-child{padding:0}.nhsapp-timeline__item:last-child:before{border:none}.nhsapp-timeline__item:before{border-left:2px solid #aeb7bd;bottom:0;content:"";display:block;left:-2px;position:absolute;top:8px;width:2px}.nhsapp-timeline__item--past:before{border-color:#005eb8}.nhsapp-timeline__badge{flex-shrink:0;z-index:1;height:16px;width:16px;margin-left:-9px;margin-top:4px;margin-right:24px}@media (min-width:40.0625em){.nhsapp-timeline__badge{height:20px;margin-left:-11px;margin-top:3px;width:20px}}.nhsapp-timeline__badge--small{height:12px;width:12px;margin-left:-7px;margin-top:6px;margin-right:26px}@media (min-width:40.0625em){.nhsapp-timeline__badge--small{height:16px;margin-left:-9px;margin-top:5px;width:16px}}.nhsapp-timeline__header{font-weight:400;margin-bottom:0}.nhsapp-timeline__header{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-timeline__header{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-timeline__header{font-size:13pt;line-height:1.25}}.nhsapp-timeline__description{margin-bottom:0;padding-top:0}.nhsapp-timeline__description{font-size:14px;font-size:.875rem;line-height:1.71429}@media (min-width:40.0625em){.nhsapp-timeline__description{font-size:16px;font-size:1rem;line-height:1.5}}@media print{.nhsapp-timeline__description{font-size:12pt;line-height:1.3}}@media (max-width:40.0525em){.nhsapp-summary-list--two-columns-mobile{display:table;table-layout:fixed;width:100%}}.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__actions,.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__key,.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__value{border-bottom:1px solid #d8dde0;display:table-cell;padding-bottom:8px;padding-right:24px;padding-top:8px}.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__row{display:table-row}@media (max-width:40.0525em){.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__key{width:50%}}.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__value{width:50%}@media (min-width:40.0625em){.nhsapp-u-hide-from-tablet{display:none!important}}@media (max-width:40.0525em){.nhsapp-u-hide-until-tablet{display:none!important}}.nhsapp-u-truncate-two-lines{margin-right:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis} \ No newline at end of file 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..acfd208c17 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, + DOCUMENT_TYPE_CONFIG, + getDocumentTypeLabel, +} from '../../../../helpers/utils/documentType'; import LinkButton from '../../../generic/linkButton/LinkButton'; type Props = { @@ -23,6 +27,17 @@ const DocumentSearchResults = ({ session.auth!.role === REPOSITORY_ROLE.GP_ADMIN || session.auth!.role === REPOSITORY_ROLE.GP_CLINICAL; + const documentTypeLabel = (doc: SearchResult): string => { + if (searchResults.length === 0) { + return ''; + } + const docType = doc.documentSnomedCodeType; + if (docType === DOCUMENT_TYPE.LLOYD_GEORGE) { + return `${getDocumentTypeLabel(docType)} V${doc.version}`; + } + return getDocumentTypeLabel(docType) ?? 'Documents'; + }; + return (

@@ -54,7 +69,7 @@ const DocumentSearchResults = ({ id={`available-files-row-${index}-document-type`} data-testid="doctype" > - {getDocumentTypeLabel(result.documentSnomedCodeType) ?? 'Other'} + {documentTypeLabel(result)} { - 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)); + + const versionHistoryLabel = documentConfig.content.getValue( + 'versionHistoryLinkLabel', + ); + const vhDescription = documentConfig.content.getValue( + 'versionHistoryLinkDescription', + ); + inputLinks.push( + VersionHistoryAction( + versionHistoryLabel, + vhDescription, + handleVersionHistoryClick, + ), + ); } } @@ -213,6 +226,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, @@ -229,9 +258,10 @@ const DocumentView = ({ }; const getRecordCard = (): React.JSX.Element => { + const heading = documentConfig.content.getValue('viewDocumentTitle'); const card = ( { const [config, setConfig] = useConfigContext(); @@ -112,7 +111,7 @@ const TestPanel = (): React.JSX.Element => { id, ...value, }; - return ; + return ; })}

Data

@@ -121,10 +120,10 @@ const TestPanel = (): React.JSX.Element => { id, ...value, }; - return ; + return ; })}

Feature Flags

- void; -}; - -const TestToggle = ({ id, checked, onChange, label }: ToggleProps): React.JSX.Element => { - return ( -
- - -
- ); -}; - -export default TestToggle; diff --git a/app/src/components/blocks/testPanel/Toggle.scss b/app/src/components/blocks/testPanel/Toggle.scss new file mode 100644 index 0000000000..954bea161b --- /dev/null +++ b/app/src/components/blocks/testPanel/Toggle.scss @@ -0,0 +1,71 @@ +$toggle-track-color: #adb5bd; +$toggle-checked-color: #007f3b; +$toggle-focus-color: #ffb81c; +$toggle-knob-color: #fff; + +.ndr-toggle-div { + margin-bottom: 8px; +} + +.ndr-toggle-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + margin: 0; +} + +// Hide the native checkbox +.ndr-toggle-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + + // Checked state + &:checked + .ndr-toggle-track { + background-color: $toggle-checked-color; + + &::after { + transform: translateX(22px); + } + } + + // Focus ring for accessibility + &:focus-visible + .ndr-toggle-track { + outline: 3px solid $toggle-focus-color; + outline-offset: 2px; + } +} + +// The track +.ndr-toggle-track { + position: relative; + display: inline-block; + width: 48px; + height: 26px; + background-color: $toggle-track-color; + border-radius: 13px; + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.2s ease; + + // The knob + &::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + background-color: $toggle-knob-color; + border-radius: 50%; + transition: transform 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } +} + +.ndr-toggle-paragraph { + margin: 0; + font-size: 14px; +} diff --git a/app/src/components/blocks/testPanel/Toggle.tsx b/app/src/components/blocks/testPanel/Toggle.tsx new file mode 100644 index 0000000000..a71ed3224b --- /dev/null +++ b/app/src/components/blocks/testPanel/Toggle.tsx @@ -0,0 +1,28 @@ +import './Toggle.scss'; + +export type ToggleProps = { + id: string; + checked: boolean; + label: string; + onChange: () => void; +}; + +const Toggle = ({ id, checked, onChange, label }: ToggleProps): React.JSX.Element => { + return ( +
+ +
+ ); +}; + +export default Toggle; 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..7ab17ba4da 100644 --- a/app/src/config/lloydGeorgeConfig.json +++ b/app/src/config/lloydGeorgeConfig.json @@ -43,6 +43,8 @@ "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." } } \ No newline at end of file diff --git a/app/src/helpers/requests/getDocumentVersionHistory.test.ts b/app/src/helpers/requests/getDocumentVersionHistory.test.ts new file mode 100644 index 0000000000..e35df76aa8 --- /dev/null +++ b/app/src/helpers/requests/getDocumentVersionHistory.test.ts @@ -0,0 +1,141 @@ +import axios from 'axios'; +import { beforeEach, describe, expect, it, Mocked, vi } from 'vitest'; +import { AuthHeaders } from '../../types/blocks/authHeaders'; +import { endpoints } from '../../types/generic/endpoints'; +import { Bundle } from '../../types/generic/fhir'; +import { + DocumentReference, + 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: [ + { + resource: { + id: 'doc-ref-123', + version: '2', + created: '2025-12-15T10:30:00Z', + custodian: 'Y12345', + fileName: 'document_v2.pdf', + contentType: 'application/pdf', + fileSize: 2048, + }, + }, + { + resource: { + id: 'doc-ref-123', + version: '1', + created: '2025-10-01T09:00:00Z', + custodian: 'A12345', + fileName: 'document_v1.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }, + }, + ], + }; + + 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.version); + 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..d72459d52c --- /dev/null +++ b/app/src/helpers/requests/getDocumentVersionHistory.ts @@ -0,0 +1,51 @@ +import axios, { AxiosError } from 'axios'; +import { AuthHeaders } from '../../types/blocks/authHeaders'; +import { endpoints } from '../../types/generic/endpoints'; +import { Bundle } from '../../types/generic/fhir'; +import { mockDocumentVersionHistoryResponse } from '../test/getMockVersionHistory'; +import { isLocal } from '../utils/isLocal'; + +export type GetDocumentVersionHistoryArgs = { + nhsNumber: string; + baseUrl: string; + baseHeaders: AuthHeaders; + documentReferenceId: string; +}; + +export type DocumentReference = { + id: string; + version: string; + created: string; + custodian: string; // TODO: this might need to be an object? tbd + fileName: string; + contentType: string; + fileSize: number; +}; + +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/test/getMockVersionHistory.ts b/app/src/helpers/test/getMockVersionHistory.ts new file mode 100644 index 0000000000..f12411a9f3 --- /dev/null +++ b/app/src/helpers/test/getMockVersionHistory.ts @@ -0,0 +1,43 @@ +import { Bundle } from '../../types/generic/fhir'; +import { DocumentReference } from '../requests/getDocumentVersionHistory'; + +export const mockDocumentVersionHistoryResponse: Bundle = { + resourceType: 'Bundle', + type: 'history', + total: 3, + entry: [ + { + resource: { + id: '2a7a270e-aa1d-532e-8648-d5d8e3defb82', + version: '3', + created: '2025-12-15T10:30:00Z', + custodian: 'Y12345', + fileName: 'document_v3.pdf', + contentType: 'application/pdf', + fileSize: 3072, + }, + }, + { + resource: { + id: 'c889dbbf-2e3a-5860-ab90-9421b5e29b86', + version: '2', + created: '2025-11-10T14:00:00Z', + custodian: 'Y12345', + fileName: 'document_v2.pdf', + contentType: 'application/pdf', + fileSize: 2048, + }, + }, + { + resource: { + id: '232865e2-c1b5-58c5-bc1c-9d355907b649', + version: '1', + created: '2025-10-01T09:00:00Z', + custodian: 'A12345', + fileName: 'document_v1.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }, + }, + ], +}; diff --git a/app/src/helpers/utils/documentType.ts b/app/src/helpers/utils/documentType.ts index e1bbb52a21..8999a3a4e6 100644 --- a/app/src/helpers/utils/documentType.ts +++ b/app/src/helpers/utils/documentType.ts @@ -1,17 +1,61 @@ 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'; +/** 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 +78,105 @@ 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; +}; + +/** + * 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 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 +203,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,16 +219,93 @@ 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 getConfigForDocTypeGeneric(DOCUMENT_TYPE.EHR) as DOCUMENT_TYPE_CONFIG; + case DOCUMENT_TYPE.EHR_ATTACHMENTS: + return getConfigForDocTypeGeneric( + DOCUMENT_TYPE.EHR_ATTACHMENTS, + ) as DOCUMENT_TYPE_CONFIG; + case DOCUMENT_TYPE.LETTERS_AND_DOCS: + 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 electronicHealthRecordConfig as DOCUMENT_TYPE_CONFIG; + return toDocTypeConfig( + electronicHealthRecordConfig as BaseDocTypeConfig, + ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; case DOCUMENT_TYPE.EHR_ATTACHMENTS: - return electronicHealthRecordAttachmentsConfig as DOCUMENT_TYPE_CONFIG; + return toDocTypeConfig( + ehrAttachmentsConfiguration as BaseDocTypeConfig, + ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; case DOCUMENT_TYPE.LETTERS_AND_DOCS: - return lettersAndDocumentsConfig as DOCUMENT_TYPE_CONFIG; + return toDocTypeConfig( + lettersAndDocumentsConfig as BaseDocTypeConfig, + ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; default: throw new Error(`No config found for document type: ${docType}`); } 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/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx new file mode 100644 index 0000000000..984d823b27 --- /dev/null +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx @@ -0,0 +1,171 @@ +import { Button } from 'nhsuk-react-components'; +import { useEffect, useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import BackButton from '../../components/generic/backButton/BackButton'; +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 { + DocumentReference as FhirDocumentReference, + getDocumentVersionHistoryResponse, +} from '../../helpers/requests/getDocumentVersionHistory'; +import { getDocumentTypeLabel } from '../../helpers/utils/documentType'; +import { getFormatDateWithAtTime } from '../../helpers/utils/formatDate'; +import { Bundle } from '../../types/generic/fhir'; +import { routes } from '../../types/generic/routes'; +import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; + +type LocationState = { + documentReference: DocumentReference; +}; + +const DocumentVersionHistoryPage = (): React.JSX.Element => { + const location = useLocation(); + const navigate = useNavigate(); + const state = location.state as LocationState | null; + const documentReference = state?.documentReference ?? null; + + const baseUrl = useBaseAPIUrl(); + const baseHeaders = useBaseAPIHeaders(); + const patientDetails = usePatient(); + const nhsNumber = patientDetails?.nhsNumber ?? ''; + + const docTypeLabel = documentReference + ? getDocumentTypeLabel(documentReference.documentSnomedCodeType) + : ''; + const pageHeader = `Version history for ${docTypeLabel.toLowerCase()}`; + useTitle({ pageTitle: pageHeader }); + + const [loading, setLoading] = useState(true); + const [versionHistory, setVersionHistory] = useState | null>( + null, + ); + + useEffect(() => { + if (!documentReference) { + navigate(routes.PATIENT_DOCUMENTS); + return; + } + const fetchVersionHistory = async (): Promise => { + try { + const response = await getDocumentVersionHistoryResponse({ + nhsNumber, + baseUrl, + baseHeaders, + documentReferenceId: documentReference.id, + }); + setVersionHistory(response); + } catch (error) { + navigate(routes.PATIENT_DOCUMENTS); + } finally { + setLoading(false); + } + }; + void fetchVersionHistory(); + }, [documentReference, nhsNumber, baseUrl, baseHeaders, navigate]); + if (loading) { + return ; + } + if (!documentReference) { + navigate(routes.PATIENT_DOCUMENTS); + return <>; + } + + const renderVersionHistoryTimeline = (): React.JSX.Element => { + if (!versionHistory || versionHistory.entry.length === 0) { + return

    No version history available for this document.

    ; + } + + return ( + + {versionHistory.entry.map((entry, index) => { + const isCurrentVersion = index === 0; + const status = isCurrentVersion + ? TimelineStatus.Active + : TimelineStatus.Inactive; + const isLastItem = index === versionHistory.entry.length - 1; + const doc = entry.resource; + const heading = `${docTypeLabel}: version ${doc.version}`; + + return ( + + + {heading} + + + {isCurrentVersion && ( + + This is the current version shown in this patient's record + + )} + + + Created by practice: {doc.custodian} on{' '} + {getFormatDateWithAtTime(doc.created)} + + + {isCurrentVersion ? ( + + View + + ) : ( +
    + + + Restore version + +
    + )} +
    + ); + })} + +
    + ); + }; + + return ( +
    + + +

    {pageHeader}

    + + {renderVersionHistoryTimeline()} +
    + ); +}; + +export default DocumentVersionHistoryPage; diff --git a/app/src/types/blocks/lloydGeorgeActions.ts b/app/src/types/blocks/lloydGeorgeActions.ts index 696e69bc94..08637e2c27 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 lloydGeorgeRecordLinks: Array = [ - { - index: 1, - label: 'Remove this document', - key: ACTION_LINK_KEY.DELETE, +export const AddAction = (label: string, onClick: () => void): LGRecordActionLink => { + return { + index: 2, + label: label, + key: ACTION_LINK_KEY.ADD, type: RECORD_ACTION.UPDATE, - unauthorised: [REPOSITORY_ROLE.GP_CLINICAL], - href: routeChildren.LLOYD_GEORGE_DELETE, + unauthorised: [], 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, + onClick, + }; +}; + +export const ReassignAction = (label: string, onClick: () => void): LGRecordActionLink => { + return { + index: 3, + label: label, + key: ACTION_LINK_KEY.REASSIGN, + type: RECORD_ACTION.UPDATE, unauthorised: [], - href: routeChildren.LLOYD_GEORGE_DOWNLOAD, + onClick, showIfRecordInStorage: true, - }, -]; + }; +}; + +export const VersionHistoryAction = ( + label: string, + description: string, + onClick: () => void, +): LGRecordActionLink => { + return { + index: 4, + label: label, + key: ACTION_LINK_KEY.HISTORY, + type: RECORD_ACTION.UPDATE, // This could be a different type if needed + unauthorised: [], + 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/generic/fhir.ts b/app/src/types/generic/fhir.ts new file mode 100644 index 0000000000..69484b1924 --- /dev/null +++ b/app/src/types/generic/fhir.ts @@ -0,0 +1,11 @@ +export type Bundle = { + resourceType: string; + type: string; + total: number; + entry: Array>; +}; + +export type BundleEntry = { + fullUrl?: string; + resource: T; +}; diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index 8c8b8454e6..799d301136 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -74,6 +74,8 @@ export enum routeChildren { DOCUMENT_REASSIGN_UPLOADING = '/patient/document-reassign-pages/uploading', DOCUMENT_REASSIGN_COMPLETE = '/patient/document-reassign-pages/complete', + DOCUMENT_VERSION_HISTORY = '/patient/document-version-history', + DOCUMENT_VIEW = '/patient/documents/view', DOCUMENT_DELETE = '/patient/documents/delete', DOCUMENT_DELETE_CONFIRMATION = '/patient/documents/delete/confirmation', From 24aa74a31d3b96b7e2517197b6b357d517e8050e Mon Sep 17 00:00:00 2001 From: Lillie Dae Date: Wed, 4 Mar 2026 18:34:54 +0000 Subject: [PATCH 2/4] [PRMP-1409] feat: Add FHIR R4 Bundle and DocumentReference resources - Implemented the FHIR R4 Bundle resource with associated enums and interfaces. - Created sample JSON files for Bundle history (bundleHistory1.fhir.json and bundleHistory2.fhir.json). - Developed the FHIR R4 DocumentReference resource with its structure and value sets. - Added unit tests for validating the structure and data of Bundle and DocumentReference resources. - Removed outdated generic FHIR types and updated routes for document version history. --- .../download_patient_files_workflow.cy.js | 4 +- app/main.html | 2 - app/package-lock.json | 50 +-- app/package.json | 2 + app/public/nhsapp-5.0.3.min.css | 1 - .../DocumentSearchResults.tsx | 20 +- .../documentView/DocumentView.test.tsx | 61 +++- .../documentView/DocumentView.tsx | 18 +- .../components/blocks/testPanel/TestPanel.tsx | 9 +- .../blocks/testPanel/TestToggle.tsx | 21 ++ .../components/blocks/testPanel/Toggle.scss | 71 ---- .../components/blocks/testPanel/Toggle.tsx | 28 -- .../recordMenuCard/RecordMenuCard.test.tsx | 10 +- .../generic/timeline/Timeline.test.tsx | 146 ++++++++ app/src/config/lloydGeorgeConfig.json | 5 +- .../requests/getDocumentSearchResults.ts | 2 +- .../getDocumentVersionHistory.test.ts | 103 +++++- .../requests/getDocumentVersionHistory.ts | 19 +- app/src/helpers/test/getMockVersionHistory.ts | 175 +++++++-- app/src/helpers/test/testBuilders.ts | 9 +- app/src/helpers/utils/documentType.test.ts | 135 ++++++- app/src/helpers/utils/documentType.ts | 50 ++- app/src/helpers/utils/fhirUtil.test.ts | 78 ++++ app/src/helpers/utils/fhirUtil.ts | 17 + app/src/helpers/utils/formatDate.test.ts | 19 + .../DocumentSearchResultsPage.test.tsx | 1 - .../DocumentSearchResultsPage.tsx | 7 + .../DocumentVersionHistoryPage.test.tsx | 189 ++++++++++ .../DocumentVersionHistoryPage.tsx | 71 ++-- app/src/styles/App.scss | 2 + app/src/types/fhirR4/baseTypes.ts | 340 ++++++++++++++++++ app/src/types/fhirR4/bundle.ts | 177 +++++++++ app/src/types/fhirR4/bundleHistory1.fhir.json | 125 +++++++ app/src/types/fhirR4/bundleHistory2.fhir.json | 161 +++++++++ app/src/types/fhirR4/documentReference.ts | 207 +++++++++++ app/src/types/fhirR4/fhir.test.ts | 322 +++++++++++++++++ app/src/types/fhirR4/valueSets.ts | 50 +++ app/src/types/generic/featureFlags.ts | 2 + app/src/types/generic/fhir.ts | 11 - app/src/types/generic/routes.ts | 3 +- 40 files changed, 2453 insertions(+), 270 deletions(-) delete mode 100644 app/public/nhsapp-5.0.3.min.css create mode 100644 app/src/components/blocks/testPanel/TestToggle.tsx delete mode 100644 app/src/components/blocks/testPanel/Toggle.scss delete mode 100644 app/src/components/blocks/testPanel/Toggle.tsx create mode 100644 app/src/components/generic/timeline/Timeline.test.tsx create mode 100644 app/src/helpers/utils/fhirUtil.test.ts create mode 100644 app/src/helpers/utils/fhirUtil.ts create mode 100644 app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.test.tsx create mode 100644 app/src/types/fhirR4/baseTypes.ts create mode 100644 app/src/types/fhirR4/bundle.ts create mode 100644 app/src/types/fhirR4/bundleHistory1.fhir.json create mode 100644 app/src/types/fhirR4/bundleHistory2.fhir.json create mode 100644 app/src/types/fhirR4/documentReference.ts create mode 100644 app/src/types/fhirR4/fhir.test.ts create mode 100644 app/src/types/fhirR4/valueSets.ts delete mode 100644 app/src/types/generic/fhir.ts 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/main.html b/app/main.html index f45a5f122a..b6f9a302ce 100644 --- a/app/main.html +++ b/app/main.html @@ -49,8 +49,6 @@ - - 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 253bdeb190..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", @@ -77,6 +78,7 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-toggle": "^4.0.5", "@types/sinon": "^21.0.0", "@types/uuid": "^11.0.0", "@types/validator": "^13.15.10", diff --git a/app/public/nhsapp-5.0.3.min.css b/app/public/nhsapp-5.0.3.min.css deleted file mode 100644 index f482ed553c..0000000000 --- a/app/public/nhsapp-5.0.3.min.css +++ /dev/null @@ -1 +0,0 @@ -.nhsapp-icon{fill:#005eb8}.nhsapp-icon--unread-indicator{fill:#d5281b;stroke:#fff}.nhsapp-icon--black{fill:#212b32}.nhsapp-icon--32{height:32px;width:32px}@media (max-width:40.0525em){.nhsapp-icon--32{height:24px;width:24px}}.nhsapp-badge{display:inline-block;background-color:#d5281b;border-radius:4px;color:#fff;font-weight:700;padding:0 8px;margin:0}.nhsapp-badge{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-badge{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-badge{font-size:13pt;line-height:1.25}}@media (min-width:40.0625em){.nhsapp-badge{padding:0 12px}}.nhsapp-badge-small{position:relative;display:inline-flex;align-items:baseline}.nhsapp-badge-small__indicator{position:relative;width:8px;height:8px;margin-right:8px;border-radius:4px;bottom:calc(.5 * (.7em - 8px));background-color:#d5281b}@media (min-width:40.0625em){.nhsapp-badge-small__indicator{position:relative;width:12px;height:12px;margin-right:12px;border-radius:6px;bottom:calc(.5 * (.7em - 12px))}}.nhsapp-badge-small--absolute .nhsapp-badge-small__indicator{position:absolute;left:-16px}@media (min-width:40.0625em){.nhsapp-badge-small--absolute .nhsapp-badge-small__indicator{left:-24px}}.nhsapp-button,.nhsapp-button.nhsuk-button--secondary::before,.nhsapp-button.nhsuk-button--secondary:active{border-radius:8px}.nhsapp-button.nhsuk-button--secondary-solid:not(:focus)::after,.nhsapp-button.nhsuk-button--secondary:not(:focus)::after{border-radius:6px!important}.nhsapp-card{background-color:#fff;border:2px solid #d8dde0;border-radius:8px;position:relative;padding:0}.nhsapp-card{margin-bottom:32px}@media (min-width:40.0625em){.nhsapp-card{margin-bottom:40px}}.nhsapp-card__title{font-weight:700;margin-bottom:0}.nhsapp-card__title{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-card__title{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-card__title{font-size:13pt;line-height:1.25}}.nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(0,94,184,.7);flex:none;height:24px;margin-right:-8px;width:24px}@media (min-width:40.0625em){.nhsapp-card .nhsapp-icon--chevron-right{height:28px;margin-right:-10px;width:28px}}.nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#7c2855}.nhsapp-card__container{display:flex;align-items:center;gap:12px;margin:0;padding:16px}@media (min-width:40.0625em){.nhsapp-card__container{gap:16px;margin:0;padding:24px}}.nhsapp-card__content{flex-grow:1}.nhsapp-card__content :last-child{margin-bottom:0}.nhsapp-card__link{font-weight:700;text-decoration:none}.nhsapp-card__link{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-card__link{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-card__link{font-size:13pt;line-height:1.25}}.nhsapp-card__link:hover{text-decoration:underline}.nhsapp-card__link::after{bottom:0;content:"";display:block;left:0;position:absolute;right:0;top:0}.nhsapp-card__description{color:#4c6272;margin:0;margin-top:4px}.nhsapp-card__description{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-card__description{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-card__description{font-size:13pt;line-height:1.25}}@media (min-width:40.0625em){.nhsapp-card__description{margin-top:8px}}.nhsapp-card__below :last-child{margin-bottom:0}.nhsapp-card__footer{border-top:1px solid #d8dde0;margin:0 16px;padding:16px 0}@media (min-width:40.0625em){.nhsapp-card__footer{margin:0 24px;padding:24px 0}}.nhsapp-card__footer :last-child{margin-bottom:0}.nhsapp-cards{list-style:none;padding:0}.nhsapp-cards{margin-bottom:32px}@media (min-width:40.0625em){.nhsapp-cards{margin-bottom:40px}}.nhsapp-cards .nhsapp-card{margin-bottom:16px}@media (min-width:40.0625em){.nhsapp-cards .nhsapp-card{margin-bottom:24px}}.nhsapp-cards .nhsapp-card:last-of-type{margin-bottom:0}.nhsapp-cards--stacked{margin-bottom:32px}@media (min-width:40.0625em){.nhsapp-cards--stacked{margin-bottom:40px}}.nhsapp-cards--stacked .nhsapp-card{border-bottom:0;border-radius:0;border-top:0;margin-bottom:0}.nhsapp-cards--stacked .nhsapp-card .nhsapp-card__container{border-bottom:1px solid #d8dde0}.nhsapp-cards--stacked .nhsapp-card:first-of-type{border-radius:8px 8px 0 0;border-top:2px solid #d8dde0}.nhsapp-cards--stacked .nhsapp-card:last-of-type{border-bottom:2px solid #d8dde0;border-radius:0 0 8px 8px}.nhsapp-cards--stacked .nhsapp-card:last-of-type .nhsapp-card__container{border-bottom:0}.nhsapp-cards--stacked .nhsapp-card:only-of-type{border-radius:8px;border-top:2px solid #d8dde0;border-bottom:2px solid #d8dde0}.nhsapp-card--secondary,.nhsapp-cards--secondary .nhsapp-card{background:0 0}.nhsapp-cards__heading{padding-top:0}.nhsapp-cards__heading{font-size:19px;font-size:1.1875rem;line-height:1.42105}@media (min-width:40.0625em){.nhsapp-cards__heading{font-size:22px;font-size:1.375rem;line-height:1.36364}}@media print{.nhsapp-cards__heading{font-size:15pt;line-height:1.25}}.nhsapp-cards__heading{margin-bottom:8px}@media (min-width:40.0625em){.nhsapp-cards__heading{margin-bottom:16px}}.nhsapp-cards__heading+.nhsapp-cards__description{margin-top:-8px}@media (min-width:40.0625em){.nhsapp-cards__heading+.nhsapp-cards__description{margin-top:-16px}}.nhsapp-cards__description{color:#4c6272;margin-bottom:12px}@media (min-width:40.0625em){.nhsapp-cards__description{margin-bottom:16px}}.nhsapp-card--pale-aqua-green,.nhsapp-cards--pale-aqua-green .nhsapp-card{background:#c9e3e0;border-color:#c9e3e0;color:#1e403d}.nhsapp-card--pale-aqua-green:first-of-type,.nhsapp-card--pale-aqua-green:last-of-type,.nhsapp-cards--pale-aqua-green .nhsapp-card:first-of-type,.nhsapp-cards--pale-aqua-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-aqua-green .nhsapp-card__container,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__container{border-color:rgba(30,64,61,.2)}.nhsapp-card--pale-aqua-green .nhsapp-card__link,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link{color:#1e403d}.nhsapp-card--pale-aqua-green .nhsapp-card__link:hover,.nhsapp-card--pale-aqua-green .nhsapp-card__link:hover:visited,.nhsapp-card--pale-aqua-green .nhsapp-card__link:visited,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:visited{color:#1e403d}.nhsapp-card--pale-aqua-green .nhsapp-card__link:focus,.nhsapp-card--pale-aqua-green .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-aqua-green .nhsapp-icon--chevron-right,.nhsapp-cards--pale-aqua-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(30,64,61,.7)}.nhsapp-card--pale-aqua-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-aqua-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#1e403d}.nhsapp-card--dark-aqua-green,.nhsapp-cards--dark-aqua-green .nhsapp-card{background:#1e403d;border-color:#1e403d;color:#fff}.nhsapp-card--dark-aqua-green:first-of-type,.nhsapp-card--dark-aqua-green:last-of-type,.nhsapp-cards--dark-aqua-green .nhsapp-card:first-of-type,.nhsapp-cards--dark-aqua-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-aqua-green .nhsapp-card__container,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-aqua-green .nhsapp-card__link,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-aqua-green .nhsapp-card__link:hover,.nhsapp-card--dark-aqua-green .nhsapp-card__link:hover:visited,.nhsapp-card--dark-aqua-green .nhsapp-card__link:visited,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-aqua-green .nhsapp-card__link:focus,.nhsapp-card--dark-aqua-green .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-aqua-green .nhsapp-icon--chevron-right,.nhsapp-cards--dark-aqua-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-aqua-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-aqua-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-blue,.nhsapp-cards--pale-blue .nhsapp-card{background:#ccdff1;border-color:#ccdff1;color:#00386e}.nhsapp-card--pale-blue:first-of-type,.nhsapp-card--pale-blue:last-of-type,.nhsapp-cards--pale-blue .nhsapp-card:first-of-type,.nhsapp-cards--pale-blue .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-blue .nhsapp-card__container,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__container{border-color:rgba(0,56,110,.2)}.nhsapp-card--pale-blue .nhsapp-card__link,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link{color:#00386e}.nhsapp-card--pale-blue .nhsapp-card__link:hover,.nhsapp-card--pale-blue .nhsapp-card__link:hover:visited,.nhsapp-card--pale-blue .nhsapp-card__link:visited,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:visited{color:#00386e}.nhsapp-card--pale-blue .nhsapp-card__link:focus,.nhsapp-card--pale-blue .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-blue .nhsapp-icon--chevron-right,.nhsapp-cards--pale-blue .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(0,56,110,.7)}.nhsapp-card--pale-blue:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-blue .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#00386e}.nhsapp-card--dark-blue,.nhsapp-cards--dark-blue .nhsapp-card{background:#00386e;border-color:#00386e;color:#fff}.nhsapp-card--dark-blue:first-of-type,.nhsapp-card--dark-blue:last-of-type,.nhsapp-cards--dark-blue .nhsapp-card:first-of-type,.nhsapp-cards--dark-blue .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-blue .nhsapp-card__container,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-blue .nhsapp-card__link,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-blue .nhsapp-card__link:hover,.nhsapp-card--dark-blue .nhsapp-card__link:hover:visited,.nhsapp-card--dark-blue .nhsapp-card__link:visited,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-blue .nhsapp-card__link:focus,.nhsapp-card--dark-blue .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-blue .nhsapp-icon--chevron-right,.nhsapp-cards--dark-blue .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-blue:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-blue .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-green,.nhsapp-cards--pale-green .nhsapp-card{background:#cce5d8;border-color:#cce5d8;color:#004c23}.nhsapp-card--pale-green:first-of-type,.nhsapp-card--pale-green:last-of-type,.nhsapp-cards--pale-green .nhsapp-card:first-of-type,.nhsapp-cards--pale-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-green .nhsapp-card__container,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__container{border-color:rgba(0,76,35,.2)}.nhsapp-card--pale-green .nhsapp-card__link,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link{color:#004c23}.nhsapp-card--pale-green .nhsapp-card__link:hover,.nhsapp-card--pale-green .nhsapp-card__link:hover:visited,.nhsapp-card--pale-green .nhsapp-card__link:visited,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:visited{color:#004c23}.nhsapp-card--pale-green .nhsapp-card__link:focus,.nhsapp-card--pale-green .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-green .nhsapp-icon--chevron-right,.nhsapp-cards--pale-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(0,76,35,.7)}.nhsapp-card--pale-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#004c23}.nhsapp-card--dark-green,.nhsapp-cards--dark-green .nhsapp-card{background:#004c23;border-color:#004c23;color:#fff}.nhsapp-card--dark-green:first-of-type,.nhsapp-card--dark-green:last-of-type,.nhsapp-cards--dark-green .nhsapp-card:first-of-type,.nhsapp-cards--dark-green .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-green .nhsapp-card__container,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-green .nhsapp-card__link,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-green .nhsapp-card__link:hover,.nhsapp-card--dark-green .nhsapp-card__link:hover:visited,.nhsapp-card--dark-green .nhsapp-card__link:visited,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-green .nhsapp-card__link:focus,.nhsapp-card--dark-green .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-green .nhsapp-icon--chevron-right,.nhsapp-cards--dark-green .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-green:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-green .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-orange,.nhsapp-cards--pale-orange .nhsapp-card{background:#fbe8cc;border-color:#fbe8cc;color:#5f3800}.nhsapp-card--pale-orange:first-of-type,.nhsapp-card--pale-orange:last-of-type,.nhsapp-cards--pale-orange .nhsapp-card:first-of-type,.nhsapp-cards--pale-orange .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-orange .nhsapp-card__container,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__container{border-color:rgba(95,56,0,.2)}.nhsapp-card--pale-orange .nhsapp-card__link,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link{color:#5f3800}.nhsapp-card--pale-orange .nhsapp-card__link:hover,.nhsapp-card--pale-orange .nhsapp-card__link:hover:visited,.nhsapp-card--pale-orange .nhsapp-card__link:visited,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:visited{color:#5f3800}.nhsapp-card--pale-orange .nhsapp-card__link:focus,.nhsapp-card--pale-orange .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-orange .nhsapp-icon--chevron-right,.nhsapp-cards--pale-orange .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(95,56,0,.7)}.nhsapp-card--pale-orange:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-orange .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#5f3800}.nhsapp-card--dark-orange,.nhsapp-cards--dark-orange .nhsapp-card{background:#5f3800;border-color:#5f3800;color:#fff}.nhsapp-card--dark-orange:first-of-type,.nhsapp-card--dark-orange:last-of-type,.nhsapp-cards--dark-orange .nhsapp-card:first-of-type,.nhsapp-cards--dark-orange .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-orange .nhsapp-card__container,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-orange .nhsapp-card__link,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-orange .nhsapp-card__link:hover,.nhsapp-card--dark-orange .nhsapp-card__link:hover:visited,.nhsapp-card--dark-orange .nhsapp-card__link:visited,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-orange .nhsapp-card__link:focus,.nhsapp-card--dark-orange .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-orange .nhsapp-icon--chevron-right,.nhsapp-cards--dark-orange .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-orange:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-orange .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-pink,.nhsapp-cards--pale-pink .nhsapp-card{background:#efd3e3;border-color:#efd3e3;color:#681645}.nhsapp-card--pale-pink:first-of-type,.nhsapp-card--pale-pink:last-of-type,.nhsapp-cards--pale-pink .nhsapp-card:first-of-type,.nhsapp-cards--pale-pink .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-pink .nhsapp-card__container,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__container{border-color:rgba(104,22,69,.2)}.nhsapp-card--pale-pink .nhsapp-card__link,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link{color:#681645}.nhsapp-card--pale-pink .nhsapp-card__link:hover,.nhsapp-card--pale-pink .nhsapp-card__link:hover:visited,.nhsapp-card--pale-pink .nhsapp-card__link:visited,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:visited{color:#681645}.nhsapp-card--pale-pink .nhsapp-card__link:focus,.nhsapp-card--pale-pink .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-pink .nhsapp-icon--chevron-right,.nhsapp-cards--pale-pink .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(104,22,69,.7)}.nhsapp-card--pale-pink:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-pink .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#681645}.nhsapp-card--dark-pink,.nhsapp-cards--dark-pink .nhsapp-card{background:#681645;border-color:#681645;color:#fff}.nhsapp-card--dark-pink:first-of-type,.nhsapp-card--dark-pink:last-of-type,.nhsapp-cards--dark-pink .nhsapp-card:first-of-type,.nhsapp-cards--dark-pink .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-pink .nhsapp-card__container,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-pink .nhsapp-card__link,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-pink .nhsapp-card__link:hover,.nhsapp-card--dark-pink .nhsapp-card__link:hover:visited,.nhsapp-card--dark-pink .nhsapp-card__link:visited,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-pink .nhsapp-card__link:focus,.nhsapp-card--dark-pink .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-pink .nhsapp-icon--chevron-right,.nhsapp-cards--dark-pink .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-pink:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-pink .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-purple,.nhsapp-cards--pale-purple .nhsapp-card{background:#ded6e8;border-color:#ded6e8;color:#402463}.nhsapp-card--pale-purple:first-of-type,.nhsapp-card--pale-purple:last-of-type,.nhsapp-cards--pale-purple .nhsapp-card:first-of-type,.nhsapp-cards--pale-purple .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-purple .nhsapp-card__container,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__container{border-color:rgba(64,36,99,.2)}.nhsapp-card--pale-purple .nhsapp-card__link,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link{color:#402463}.nhsapp-card--pale-purple .nhsapp-card__link:hover,.nhsapp-card--pale-purple .nhsapp-card__link:hover:visited,.nhsapp-card--pale-purple .nhsapp-card__link:visited,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:visited{color:#402463}.nhsapp-card--pale-purple .nhsapp-card__link:focus,.nhsapp-card--pale-purple .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-purple .nhsapp-icon--chevron-right,.nhsapp-cards--pale-purple .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(64,36,99,.7)}.nhsapp-card--pale-purple:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-purple .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#402463}.nhsapp-card--dark-purple,.nhsapp-cards--dark-purple .nhsapp-card{background:#402463;border-color:#402463;color:#fff}.nhsapp-card--dark-purple:first-of-type,.nhsapp-card--dark-purple:last-of-type,.nhsapp-cards--dark-purple .nhsapp-card:first-of-type,.nhsapp-cards--dark-purple .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-purple .nhsapp-card__container,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-purple .nhsapp-card__link,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-purple .nhsapp-card__link:hover,.nhsapp-card--dark-purple .nhsapp-card__link:hover:visited,.nhsapp-card--dark-purple .nhsapp-card__link:visited,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-purple .nhsapp-card__link:focus,.nhsapp-card--dark-purple .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-purple .nhsapp-icon--chevron-right,.nhsapp-cards--dark-purple .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-purple:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-purple .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-red,.nhsapp-cards--pale-red .nhsapp-card{background:#f7d4d1;border-color:#f7d4d1;color:#801810}.nhsapp-card--pale-red:first-of-type,.nhsapp-card--pale-red:last-of-type,.nhsapp-cards--pale-red .nhsapp-card:first-of-type,.nhsapp-cards--pale-red .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-red .nhsapp-card__container,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__container{border-color:rgba(128,24,16,.2)}.nhsapp-card--pale-red .nhsapp-card__link,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link{color:#801810}.nhsapp-card--pale-red .nhsapp-card__link:hover,.nhsapp-card--pale-red .nhsapp-card__link:hover:visited,.nhsapp-card--pale-red .nhsapp-card__link:visited,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:visited{color:#801810}.nhsapp-card--pale-red .nhsapp-card__link:focus,.nhsapp-card--pale-red .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-red .nhsapp-icon--chevron-right,.nhsapp-cards--pale-red .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(128,24,16,.7)}.nhsapp-card--pale-red:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-red .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#801810}.nhsapp-card--dark-red,.nhsapp-cards--dark-red .nhsapp-card{background:#801810;border-color:#801810;color:#fff}.nhsapp-card--dark-red:first-of-type,.nhsapp-card--dark-red:last-of-type,.nhsapp-cards--dark-red .nhsapp-card:first-of-type,.nhsapp-cards--dark-red .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-red .nhsapp-card__container,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-red .nhsapp-card__link,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-red .nhsapp-card__link:hover,.nhsapp-card--dark-red .nhsapp-card__link:hover:visited,.nhsapp-card--dark-red .nhsapp-card__link:visited,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-red .nhsapp-card__link:focus,.nhsapp-card--dark-red .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-red .nhsapp-icon--chevron-right,.nhsapp-cards--dark-red .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-red:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-red .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--pale-yellow,.nhsapp-cards--pale-yellow .nhsapp-card{background:#fff7b1;border-color:#fff7b1;color:#4c4612}.nhsapp-card--pale-yellow:first-of-type,.nhsapp-card--pale-yellow:last-of-type,.nhsapp-cards--pale-yellow .nhsapp-card:first-of-type,.nhsapp-cards--pale-yellow .nhsapp-card:last-of-type{border:0}.nhsapp-card--pale-yellow .nhsapp-card__container,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__container{border-color:rgba(76,70,18,.2)}.nhsapp-card--pale-yellow .nhsapp-card__link,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link{color:#4c4612}.nhsapp-card--pale-yellow .nhsapp-card__link:hover,.nhsapp-card--pale-yellow .nhsapp-card__link:hover:visited,.nhsapp-card--pale-yellow .nhsapp-card__link:visited,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:visited{color:#4c4612}.nhsapp-card--pale-yellow .nhsapp-card__link:focus,.nhsapp-card--pale-yellow .nhsapp-card__link:focus:hover,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--pale-yellow .nhsapp-icon--chevron-right,.nhsapp-cards--pale-yellow .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(76,70,18,.7)}.nhsapp-card--pale-yellow:hover .nhsapp-icon--chevron-right,.nhsapp-cards--pale-yellow .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#4c4612}.nhsapp-card--dark-yellow,.nhsapp-cards--dark-yellow .nhsapp-card{background:#4c4612;border-color:#4c4612;color:#fff}.nhsapp-card--dark-yellow:first-of-type,.nhsapp-card--dark-yellow:last-of-type,.nhsapp-cards--dark-yellow .nhsapp-card:first-of-type,.nhsapp-cards--dark-yellow .nhsapp-card:last-of-type{border:0}.nhsapp-card--dark-yellow .nhsapp-card__container,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__container{border-color:rgba(255,255,255,.2)}.nhsapp-card--dark-yellow .nhsapp-card__link,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link{color:#fff}.nhsapp-card--dark-yellow .nhsapp-card__link:hover,.nhsapp-card--dark-yellow .nhsapp-card__link:hover:visited,.nhsapp-card--dark-yellow .nhsapp-card__link:visited,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:hover,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:hover:visited,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:visited{color:#fff}.nhsapp-card--dark-yellow .nhsapp-card__link:focus,.nhsapp-card--dark-yellow .nhsapp-card__link:focus:hover,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:focus,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-card__link:focus:hover{color:#212b32}.nhsapp-card--dark-yellow .nhsapp-icon--chevron-right,.nhsapp-cards--dark-yellow .nhsapp-card .nhsapp-icon--chevron-right{fill:rgba(255,255,255,.7)}.nhsapp-card--dark-yellow:hover .nhsapp-icon--chevron-right,.nhsapp-cards--dark-yellow .nhsapp-card:hover .nhsapp-icon--chevron-right{fill:#fff}.nhsapp-card--with-media .nhsapp-card__img{border-radius:8px 8px 0 0;display:block;max-width:100%}.nhsapp-card--with-media .nhsapp-card__container{padding:24px}@media (min-width:40.0625em){.nhsapp-card--with-media .nhsapp-card__container{padding:32px}}@media (min-width:48.0625em){.nhsapp-card--with-media{display:flex}.nhsapp-card--with-media .nhsapp-card__media{display:flex;flex:2 0}.nhsapp-card--with-media .nhsapp-card__img{border-radius:8px 0 0 8px;flex:none}.nhsapp-card--with-media .nhsapp-card__container{flex:2 0;gap:32px}}@media (min-width:61.875em){.nhsapp-card--with-media .nhsapp-card__media{flex-grow:2}.nhsapp-card--with-media .nhsapp-card__container{flex-grow:3}}.nhsapp-tag{background-color:#ccdff1;border:1px solid transparent;border-radius:2px;color:#00386e;display:inline-block;padding:3px 9px}.nhsapp-tag{font-weight:400}.nhsapp-tag{font-size:14px;font-size:.875rem;line-height:1.25}@media (min-width:40.0625em){.nhsapp-tag{font-size:16px;font-size:1rem;line-height:1.25}}@media print{.nhsapp-tag{font-size:12pt;line-height:1.25}}@media (min-width:40.0625em){.nhsapp-tag{line-height:1.4285em}}.nhsapp-tag--white{background-color:#fff;border-color:#d8dde0;color:#212b32}.nhsapp-tag--grey{background-color:#d8dde0;color:#212b32}.nhsapp-tag--green{background-color:#cce5d8;color:#004c23}.nhsapp-tag--aqua-green{background-color:#c9e3e0;color:#1e403d}.nhsapp-tag--blue{background-color:#ccdff1;color:#00386e}.nhsapp-tag--purple{background-color:#ded6e8;color:#402463}.nhsapp-tag--pink{background-color:#efd3e3;color:#681645}.nhsapp-tag--red{background-color:#f7d4d1;color:#801810}.nhsapp-tag--orange{background-color:#fbe8cc;color:#5f3800}.nhsapp-tag--yellow{background-color:#fff7b1;color:#4c4612}.nhsapp-timeline{list-style:none;padding:0}.nhsapp-timeline{margin-bottom:24px}@media (min-width:40.0625em){.nhsapp-timeline{margin-bottom:32px}}.nhsapp-timeline{padding-top:8px}@media (min-width:40.0625em){.nhsapp-timeline{padding-top:8px}}.nhsapp-timeline__item{display:flex;margin-bottom:0;margin-left:12px;margin-top:-6px;position:relative}.nhsapp-timeline__item{padding-bottom:24px}@media (min-width:40.0625em){.nhsapp-timeline__item{padding-bottom:32px}}.nhsapp-timeline__item:last-child{padding:0}.nhsapp-timeline__item:last-child:before{border:none}.nhsapp-timeline__item:before{border-left:2px solid #aeb7bd;bottom:0;content:"";display:block;left:-2px;position:absolute;top:8px;width:2px}.nhsapp-timeline__item--past:before{border-color:#005eb8}.nhsapp-timeline__badge{flex-shrink:0;z-index:1;height:16px;width:16px;margin-left:-9px;margin-top:4px;margin-right:24px}@media (min-width:40.0625em){.nhsapp-timeline__badge{height:20px;margin-left:-11px;margin-top:3px;width:20px}}.nhsapp-timeline__badge--small{height:12px;width:12px;margin-left:-7px;margin-top:6px;margin-right:26px}@media (min-width:40.0625em){.nhsapp-timeline__badge--small{height:16px;margin-left:-9px;margin-top:5px;width:16px}}.nhsapp-timeline__header{font-weight:400;margin-bottom:0}.nhsapp-timeline__header{font-size:16px;font-size:1rem;line-height:1.5}@media (min-width:40.0625em){.nhsapp-timeline__header{font-size:19px;font-size:1.1875rem;line-height:1.47368}}@media print{.nhsapp-timeline__header{font-size:13pt;line-height:1.25}}.nhsapp-timeline__description{margin-bottom:0;padding-top:0}.nhsapp-timeline__description{font-size:14px;font-size:.875rem;line-height:1.71429}@media (min-width:40.0625em){.nhsapp-timeline__description{font-size:16px;font-size:1rem;line-height:1.5}}@media print{.nhsapp-timeline__description{font-size:12pt;line-height:1.3}}@media (max-width:40.0525em){.nhsapp-summary-list--two-columns-mobile{display:table;table-layout:fixed;width:100%}}.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__actions,.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__key,.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__value{border-bottom:1px solid #d8dde0;display:table-cell;padding-bottom:8px;padding-right:24px;padding-top:8px}.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__row{display:table-row}@media (max-width:40.0525em){.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__key{width:50%}}.nhsapp-summary-list--two-columns-mobile .nhsuk-summary-list__value{width:50%}@media (min-width:40.0625em){.nhsapp-u-hide-from-tablet{display:none!important}}@media (max-width:40.0525em){.nhsapp-u-hide-until-tablet{display:none!important}}.nhsapp-u-truncate-two-lines{margin-right:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis} \ No newline at end of file diff --git a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx index acfd208c17..a855ade5a2 100644 --- a/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx +++ b/app/src/components/blocks/_patientDocuments/documentSearchResults/DocumentSearchResults.tsx @@ -4,8 +4,8 @@ import { useSessionContext } from '../../../../providers/sessionProvider/Session import { REPOSITORY_ROLE } from '../../../../types/generic/authRole'; import { getFormattedDate } from '../../../../helpers/utils/formatDate'; import { - DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG, + getConfigForDocTypeGeneric, getDocumentTypeLabel, } from '../../../../helpers/utils/documentType'; import LinkButton from '../../../generic/linkButton/LinkButton'; @@ -28,14 +28,16 @@ const DocumentSearchResults = ({ session.auth!.role === REPOSITORY_ROLE.GP_CLINICAL; const documentTypeLabel = (doc: SearchResult): string => { - if (searchResults.length === 0) { - return ''; - } - const docType = doc.documentSnomedCodeType; - if (docType === DOCUMENT_TYPE.LLOYD_GEORGE) { - return `${getDocumentTypeLabel(docType)} V${doc.version}`; - } - return getDocumentTypeLabel(docType) ?? 'Documents'; + let docconfig = getConfigForDocTypeGeneric(doc.documentSnomedCodeType); + + const heading = docconfig.content.getValueFormatString( + 'searchResultDocumentTypeLabel', + { + version: doc.version, + }, + ); + + return heading ?? getDocumentTypeLabel(doc.documentSnomedCodeType) ?? 'Documents'; }; return ( diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx index 0bea07ac5e..9ab6268a1e 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.test.tsx @@ -3,7 +3,11 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import DocumentView from './DocumentView'; import usePatient from '../../../../helpers/hooks/usePatient'; import useTitle from '../../../../helpers/hooks/useTitle'; -import { DOCUMENT_TYPE, getConfigForDocType } from '../../../../helpers/utils/documentType'; +import { + DOCUMENT_TYPE, + GetConfigForDocTypeGenericType, + getConfigForDocTypeGeneric, +} from '../../../../helpers/utils/documentType'; import { DocumentReference } from '../../../../types/pages/documentSearchResultsPage/types'; import { routeChildren, routes } from '../../../../types/generic/routes'; import { buildDocumentConfig, buildPatientDetails } from '../../../../helpers/test/testBuilders'; @@ -22,11 +26,16 @@ vi.mock('../../../../helpers/hooks/usePatient'); vi.mock('../../../../helpers/hooks/useConfig'); vi.mock('../../../../helpers/hooks/useTitle'); vi.mock('../../../../helpers/hooks/useRole'); +var realGetConfigForDocTypeGeneric: GetConfigForDocTypeGenericType; + vi.mock('../../../../helpers/utils/documentType', async () => { - 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', () => ({ @@ -129,10 +138,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 +360,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 +412,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 +465,32 @@ describe('DocumentView', () => { }), ); }); + + it('navigates to version history page when version history action is triggered', async () => { + mockUseConfig.mockReturnValue({ + featureFlags: { + versionHistoryEnabled: 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', () => { diff --git a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx index 83f654e454..82007a4b89 100644 --- a/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx +++ b/app/src/components/blocks/_patientDocuments/documentView/DocumentView.tsx @@ -146,21 +146,23 @@ const DocumentView = ({ if (canAddFiles) { inputLinks.push( AddAction( - documentConfig.content.getValue('addFilesLinkLabel'), + documentConfig.content.getValue('addFilesLinkLabel')!, handleAddFilesClick, ), ); if (config.featureFlags.documentCorrectEnabled) { - const label = documentConfig.content.getValue('reassignPagesLinkLabel'); + 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, @@ -257,8 +259,10 @@ const DocumentView = ({ }, 0); }; - const getRecordCard = (): React.JSX.Element => { - const heading = documentConfig.content.getValue('viewDocumentTitle'); + const GetRecordCard = (): React.JSX.Element => { + const heading = documentConfig.content.getValueFormatString('viewDocumentTitle', { + version: documentReference.version, + })!; const card = ( - {documentReference.url ? getRecordCard() : } + {documentReference.url ? : } ); diff --git a/app/src/components/blocks/testPanel/TestPanel.tsx b/app/src/components/blocks/testPanel/TestPanel.tsx index 32eb7d51a7..35869008b4 100644 --- a/app/src/components/blocks/testPanel/TestPanel.tsx +++ b/app/src/components/blocks/testPanel/TestPanel.tsx @@ -1,8 +1,9 @@ +import 'react-toggle/style.css'; import { isLocal } from '../../../helpers/utils/isLocal'; import { LocalFlags, useConfigContext } from '../../../providers/configProvider/ConfigProvider'; import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; import { FeatureFlags } from '../../../types/generic/featureFlags'; -import Toggle, { ToggleProps } from './Toggle'; +import TestToggle, { ToggleProps } from './TestToggle'; const TestPanel = (): React.JSX.Element => { const [config, setConfig] = useConfigContext(); @@ -111,7 +112,7 @@ const TestPanel = (): React.JSX.Element => { id, ...value, }; - return ; + return ; })}

    Data

    @@ -120,10 +121,10 @@ const TestPanel = (): React.JSX.Element => { id, ...value, }; - return ; + return ; })}

    Feature Flags

    - void; +}; + +const TestToggle = ({ id, checked, onChange, label }: ToggleProps): React.JSX.Element => { + return ( +
    + + +
    + ); +}; + +export default TestToggle; diff --git a/app/src/components/blocks/testPanel/Toggle.scss b/app/src/components/blocks/testPanel/Toggle.scss deleted file mode 100644 index 954bea161b..0000000000 --- a/app/src/components/blocks/testPanel/Toggle.scss +++ /dev/null @@ -1,71 +0,0 @@ -$toggle-track-color: #adb5bd; -$toggle-checked-color: #007f3b; -$toggle-focus-color: #ffb81c; -$toggle-knob-color: #fff; - -.ndr-toggle-div { - margin-bottom: 8px; -} - -.ndr-toggle-label { - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - margin: 0; -} - -// Hide the native checkbox -.ndr-toggle-input { - position: absolute; - opacity: 0; - width: 0; - height: 0; - - // Checked state - &:checked + .ndr-toggle-track { - background-color: $toggle-checked-color; - - &::after { - transform: translateX(22px); - } - } - - // Focus ring for accessibility - &:focus-visible + .ndr-toggle-track { - outline: 3px solid $toggle-focus-color; - outline-offset: 2px; - } -} - -// The track -.ndr-toggle-track { - position: relative; - display: inline-block; - width: 48px; - height: 26px; - background-color: $toggle-track-color; - border-radius: 13px; - cursor: pointer; - flex-shrink: 0; - transition: background-color 0.2s ease; - - // The knob - &::after { - content: ''; - position: absolute; - top: 3px; - left: 3px; - width: 20px; - height: 20px; - background-color: $toggle-knob-color; - border-radius: 50%; - transition: transform 0.2s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - } -} - -.ndr-toggle-paragraph { - margin: 0; - font-size: 14px; -} diff --git a/app/src/components/blocks/testPanel/Toggle.tsx b/app/src/components/blocks/testPanel/Toggle.tsx deleted file mode 100644 index a71ed3224b..0000000000 --- a/app/src/components/blocks/testPanel/Toggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import './Toggle.scss'; - -export type ToggleProps = { - id: string; - checked: boolean; - label: string; - onChange: () => void; -}; - -const Toggle = ({ id, checked, onChange, label }: ToggleProps): React.JSX.Element => { - return ( -
    - -
    - ); -}; - -export default Toggle; 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/config/lloydGeorgeConfig.json b/app/src/config/lloydGeorgeConfig.json index 7ab17ba4da..a122ee934e 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": [ @@ -45,6 +45,7 @@ "reassignPagesLinkLabel": "Reassign pages in these notes to another patient", "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." + "versionHistoryLinkDescription": "View or restore other versions if, for example, there's a mistake in these notes.", + "searchResultDocumentTypeLabel": "Scanned paper notes V{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 index e35df76aa8..e3529e0ae4 100644 --- a/app/src/helpers/requests/getDocumentVersionHistory.test.ts +++ b/app/src/helpers/requests/getDocumentVersionHistory.test.ts @@ -1,10 +1,13 @@ 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 { Bundle } from '../../types/generic/fhir'; import { - DocumentReference, GetDocumentVersionHistoryArgs, getDocumentVersionHistoryResponse, } from './getDocumentVersionHistory'; @@ -25,31 +28,99 @@ describe('getDocumentVersionHistoryResponse', () => { documentReferenceId: 'doc-ref-123', }; - const mockResponse: Bundle = { + const mockResponse: Bundle = { resourceType: 'Bundle', type: 'history', total: 2, entry: [ { + fullUrl: 'urn:uuid:doc-ref-123', resource: { + resourceType: 'DocumentReference', id: 'doc-ref-123', - version: '2', - created: '2025-12-15T10:30:00Z', - custodian: 'Y12345', - fileName: 'document_v2.pdf', - contentType: 'application/pdf', - fileSize: 2048, + 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', - version: '1', - created: '2025-10-01T09:00:00Z', - custodian: 'A12345', - fileName: 'document_v1.pdf', - contentType: 'application/pdf', - fileSize: 1024, + 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', + }, + }, + ], }, }, ], @@ -134,7 +205,7 @@ describe('getDocumentVersionHistoryResponse', () => { const module = await import('./getDocumentVersionHistory'); const result = await module.getDocumentVersionHistoryResponse(mockArgs); - const versions = result.entry.map((e) => e.resource.version); + 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 index d72459d52c..8f9bb53063 100644 --- a/app/src/helpers/requests/getDocumentVersionHistory.ts +++ b/app/src/helpers/requests/getDocumentVersionHistory.ts @@ -1,10 +1,13 @@ 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 { Bundle } from '../../types/generic/fhir'; 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; @@ -12,26 +15,16 @@ export type GetDocumentVersionHistoryArgs = { documentReferenceId: string; }; -export type DocumentReference = { - id: string; - version: string; - created: string; - custodian: string; // TODO: this might need to be an object? tbd - fileName: string; - contentType: string; - fileSize: number; -}; - export const getDocumentVersionHistoryResponse = async ({ nhsNumber, baseUrl, baseHeaders, documentReferenceId, -}: GetDocumentVersionHistoryArgs): Promise> => { +}: GetDocumentVersionHistoryArgs): Promise> => { const gatewayUrl = baseUrl + endpoints.DOCUMENT_REFERENCE + `/${documentReferenceId}/_history`; try { - const { data } = await axios.get>(gatewayUrl, { + const { data } = await axios.get>(gatewayUrl, { headers: { ...baseHeaders, }, diff --git a/app/src/helpers/test/getMockVersionHistory.ts b/app/src/helpers/test/getMockVersionHistory.ts index f12411a9f3..0b873ccdbd 100644 --- a/app/src/helpers/test/getMockVersionHistory.ts +++ b/app/src/helpers/test/getMockVersionHistory.ts @@ -1,42 +1,175 @@ -import { Bundle } from '../../types/generic/fhir'; -import { DocumentReference } from '../requests/getDocumentVersionHistory'; +import { Bundle } from '../../types/fhirR4/bundle'; +import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; +import { DocumentReferenceStatus } from '../../types/fhirR4/documentReference'; -export const mockDocumentVersionHistoryResponse: Bundle = { +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', - version: '3', - created: '2025-12-15T10:30:00Z', - custodian: 'Y12345', - fileName: 'document_v3.pdf', - contentType: 'application/pdf', - fileSize: 3072, + 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: 'https://documents.example.com/2a7a270e-aa1d-532e-8648-d5d8e3defb82', + 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', - version: '2', - created: '2025-11-10T14:00:00Z', - custodian: 'Y12345', - fileName: 'document_v2.pdf', - contentType: 'application/pdf', - fileSize: 2048, + 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: 'https://documents.example.com/c889dbbf-2e3a-5860-ab90-9421b5e29b86', + 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', - version: '1', - created: '2025-10-01T09:00:00Z', - custodian: 'A12345', - fileName: 'document_v1.pdf', - contentType: 'application/pdf', - fileSize: 1024, + 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: 'https://documents.example.com/232865e2-c1b5-58c5-bc1c-9d355907b649', + 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 8999a3a4e6..1d9f1d63c1 100644 --- a/app/src/helpers/utils/documentType.ts +++ b/app/src/helpers/utils/documentType.ts @@ -22,7 +22,8 @@ export enum DOCUMENT_TYPE { export type LGContentKeys = | ContentKeys | 'versionHistoryLinkLabel' - | 'versionHistoryLinkDescription'; + | 'versionHistoryLinkDescription' + | 'searchResultDocumentTypeLabel'; /** Content keys available to Electronic Health Record documents. */ export type EhrContentKeys = ContentKeys; /** Content keys available to EHR Attachments documents. */ @@ -114,7 +115,22 @@ export type IndividualDocumentTypeContentUtil = Record(key: TKeys): TReturn; + 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; }; /** @@ -143,11 +159,35 @@ export const createDocumentTypeContent = ( content: Record, ): IndividualDocumentTypeContentUtil => ({ ...content, - getValue(key: TKeys): TReturn { + 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; }, @@ -307,6 +347,8 @@ export const getConfigForDocTypeGeneric = ( lettersAndDocumentsConfig as BaseDocTypeConfig, ) as unknown as DOCUMENT_TYPE_CONFIG_GENERIC; default: - throw new Error(`No config found for document type: ${docType}`); + 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..219c116a2b --- /dev/null +++ b/app/src/helpers/utils/fhirUtil.test.ts @@ -0,0 +1,78 @@ +import { Bundle } from '../../types/fhirR4/bundle'; +import bundleHistory1Json from '../../types/fhirR4/bundleHistory1.fhir.json'; +import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; +import { getCreatedDate, getCustodianValue, getVersionId } from './fhirUtil'; + +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(''); + }); + }); +}); diff --git a/app/src/helpers/utils/fhirUtil.ts b/app/src/helpers/utils/fhirUtil.ts new file mode 100644 index 0000000000..05bf5525b6 --- /dev/null +++ b/app/src/helpers/utils/fhirUtil.ts @@ -0,0 +1,17 @@ +import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; + +/** + * 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 ?? ''; 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/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..6b368e6018 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx @@ -34,6 +34,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,6 +194,12 @@ const DocumentSearchResultsPage = (): React.JSX.Element => { /> } /> + + } + /> { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: (): Mock => mockNavigate, + useLocation: (): unknown => mockUseLocation(), + Link: ({ children, to, ...props }: any): JSX.Element => ( + + {children} + + ), + }; +}); + +vi.mock('../../helpers/hooks/usePatient'); +vi.mock('../../helpers/hooks/useBaseAPIUrl'); +vi.mock('../../helpers/hooks/useBaseAPIHeaders'); +vi.mock('../../helpers/requests/getDocumentVersionHistory'); +vi.mock('../../helpers/hooks/useTitle'); + +const mockedUsePatient = usePatient as Mock; +const mockUseBaseAPIUrl = useBaseAPIUrl as Mock; +const mockUseBaseAPIHeaders = useBaseAPIHeaders as Mock; +const mockGetDocumentVersionHistoryResponse = getDocumentVersionHistoryResponse as Mock; + +const mockPatientDetails = buildPatientDetails(); +const mockDocumentReference = buildSearchResult({ + documentSnomedCodeType: DOCUMENT_TYPE.LLOYD_GEORGE, + id: 'doc-ref-123', +}); + +const renderPage = (): RenderResult => + render(); + +describe('DocumentVersionHistoryPage', () => { + beforeEach(() => { + import.meta.env.VITE_ENVIRONMENT = 'vitest'; + mockedUsePatient.mockReturnValue(mockPatientDetails); + mockUseBaseAPIUrl.mockReturnValue('http://localhost'); + mockUseBaseAPIHeaders.mockReturnValue({ Authorization: 'Bearer token' }); + mockUseLocation.mockReturnValue({ + state: { documentReference: mockDocumentReference }, + }); + mockGetDocumentVersionHistoryResponse.mockResolvedValue(mockDocumentVersionHistoryResponse); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('loading state', () => { + it('renders a spinner while the version history is loading', () => { + mockGetDocumentVersionHistoryResponse.mockReturnValue(new Promise(() => {})); + + renderPage(); + + expect(screen.getByText('Loading version history')).toBeInTheDocument(); + }); + }); + + describe('page structure', () => { + it('renders the back button', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('go-back-button')).toBeInTheDocument(); + }); + }); + + it('renders the page heading with the correct document type label', async () => { + renderPage(); + + await waitFor(() => { + expect( + screen.getByRole('heading', { + name: /version history for scanned paper notes/i, + }), + ).toBeInTheDocument(); + }); + }); + }); + + describe('version history timeline', () => { + it('renders "no version history" message when the bundle has an empty entries array', async () => { + mockGetDocumentVersionHistoryResponse.mockResolvedValue({ + resourceType: 'Bundle', + type: 'history', + total: 0, + entry: [], + }); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByText('No version history available for this document.'), + ).toBeInTheDocument(); + }); + }); + + it('renders "no version history" message when the bundle has no entry property', async () => { + mockGetDocumentVersionHistoryResponse.mockResolvedValue({ + resourceType: 'Bundle', + type: 'history', + total: 0, + }); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByText('No version history available for this document.'), + ).toBeInTheDocument(); + }); + }); + + it('renders all version history entries with correct headings', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Scanned paper notes: version 3')).toBeInTheDocument(); + expect(screen.getByText('Scanned paper notes: version 2')).toBeInTheDocument(); + expect(screen.getByText('Scanned paper notes: version 1')).toBeInTheDocument(); + }); + }); + + it('shows "this is the current version" only for the first (most recent) entry', async () => { + renderPage(); + + await waitFor(() => { + const currentVersionMessages = screen.getAllByText( + "This is the current version shown in this patient's record", + ); + expect(currentVersionMessages).toHaveLength(1); + }); + }); + + it('renders a "View" link (not a button) for the current version', async () => { + renderPage(); + + await waitFor(() => { + const viewCurrentLink = screen.getByTestId('view-version-3'); + expect(viewCurrentLink.tagName.toLowerCase()).toBe('a'); + expect(viewCurrentLink).toHaveTextContent('View'); + }); + }); + + it('renders a "View" button and a "Restore version" link for each older version', async () => { + renderPage(); + + await waitFor(() => { + const viewVersion2 = screen.getByTestId('view-version-2'); + expect(viewVersion2.tagName.toLowerCase()).toBe('button'); + + const restoreVersion2 = screen.getByTestId('restore-version-2'); + expect(restoreVersion2).toHaveTextContent('Restore version'); + + const viewVersion1 = screen.getByTestId('view-version-1'); + expect(viewVersion1.tagName.toLowerCase()).toBe('button'); + + const restoreVersion1 = screen.getByTestId('restore-version-1'); + expect(restoreVersion1).toHaveTextContent('Restore version'); + }); + }); + + it('does not render a "Restore version" link for the current version', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.queryByTestId('restore-version-3')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx index 984d823b27..9b427f74a4 100644 --- a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx @@ -1,33 +1,31 @@ import { Button } from 'nhsuk-react-components'; import { useEffect, useState } from 'react'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; +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 { - DocumentReference as FhirDocumentReference, - getDocumentVersionHistoryResponse, -} from '../../helpers/requests/getDocumentVersionHistory'; +import { getDocumentVersionHistoryResponse } from '../../helpers/requests/getDocumentVersionHistory'; import { getDocumentTypeLabel } from '../../helpers/utils/documentType'; +import { getCreatedDate, getCustodianValue, getVersionId } from '../../helpers/utils/fhirUtil'; import { getFormatDateWithAtTime } from '../../helpers/utils/formatDate'; -import { Bundle } from '../../types/generic/fhir'; +import { Bundle } from '../../types/fhirR4/bundle'; +import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; import { routes } from '../../types/generic/routes'; import { DocumentReference } from '../../types/pages/documentSearchResultsPage/types'; -type LocationState = { - documentReference: DocumentReference; +type DocumentVersionHistoryPageProps = { + documentReference: DocumentReference | null; }; -const DocumentVersionHistoryPage = (): React.JSX.Element => { - const location = useLocation(); +const DocumentVersionHistoryPage = ({ + documentReference, +}: DocumentVersionHistoryPageProps): React.JSX.Element => { const navigate = useNavigate(); - const state = location.state as LocationState | null; - const documentReference = state?.documentReference ?? null; - const baseUrl = useBaseAPIUrl(); const baseHeaders = useBaseAPIHeaders(); const patientDetails = usePatient(); @@ -58,7 +56,7 @@ const DocumentVersionHistoryPage = (): React.JSX.Element => { documentReferenceId: documentReference.id, }); setVersionHistory(response); - } catch (error) { + } catch { navigate(routes.PATIENT_DOCUMENTS); } finally { setLoading(false); @@ -75,7 +73,7 @@ const DocumentVersionHistoryPage = (): React.JSX.Element => { } const renderVersionHistoryTimeline = (): React.JSX.Element => { - if (!versionHistory || versionHistory.entry.length === 0) { + if (!versionHistory?.entry || versionHistory.entry.length === 0) { return

    No version history available for this document.

    ; } @@ -86,15 +84,16 @@ const DocumentVersionHistoryPage = (): React.JSX.Element => { const status = isCurrentVersion ? TimelineStatus.Active : TimelineStatus.Inactive; - const isLastItem = index === versionHistory.entry.length - 1; + const isLastItem = index === versionHistory.entry!.length - 1; const doc = entry.resource; - const heading = `${docTypeLabel}: version ${doc.version}`; + const version = getVersionId(doc); + const heading = `${docTypeLabel}: version ${version}`; return ( { {isCurrentVersion && ( - - This is the current version shown in this patient's record + + This is the current version shown in this patient's record )} - - Created by practice: {doc.custodian} on{' '} - {getFormatDateWithAtTime(doc.created)} - + {isCurrentVersion ? ( View ) : ( -
    +
    Restore version @@ -148,18 +142,13 @@ const DocumentVersionHistoryPage = (): React.JSX.Element => { ); })} - ); }; return (
    - +

    {pageHeader}

    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/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..87be6e27b1 --- /dev/null +++ b/app/src/types/fhirR4/bundle.ts @@ -0,0 +1,177 @@ +/** + * 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 */ + search?: BundleEntrySearch; + /** Additional execution information (transaction/batch/history) */ + request?: BundleEntryRequest; + /** Results of execution (transaction/batch/history) */ + response?: BundleEntryResponse; +} + +// ─── 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/fhir.ts b/app/src/types/generic/fhir.ts deleted file mode 100644 index 69484b1924..0000000000 --- a/app/src/types/generic/fhir.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type Bundle = { - resourceType: string; - type: string; - total: number; - entry: Array>; -}; - -export type BundleEntry = { - fullUrl?: string; - resource: T; -}; diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index 799d301136..826b733920 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -74,7 +74,8 @@ export enum routeChildren { DOCUMENT_REASSIGN_UPLOADING = '/patient/document-reassign-pages/uploading', DOCUMENT_REASSIGN_COMPLETE = '/patient/document-reassign-pages/complete', - DOCUMENT_VERSION_HISTORY = '/patient/document-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', From 7026f2aa0571b7aa9ecaa5cd97993a5184a13ad5 Mon Sep 17 00:00:00 2001 From: Lillie Dae Date: Thu, 19 Mar 2026 15:24:14 +0000 Subject: [PATCH 3/4] fix: Prevent duplicate fetch of document version history and sort history entiries --- .../DocumentVersionHistoryPage.tsx | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx index 9b427f74a4..f7fb312e04 100644 --- a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx @@ -1,5 +1,5 @@ import { Button } from 'nhsuk-react-components'; -import { useEffect, useState } from 'react'; +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'; @@ -28,6 +28,7 @@ const DocumentVersionHistoryPage = ({ const navigate = useNavigate(); const baseUrl = useBaseAPIUrl(); const baseHeaders = useBaseAPIHeaders(); + const versionHistoryRef = useRef(false); const patientDetails = usePatient(); const nhsNumber = patientDetails?.nhsNumber ?? ''; @@ -47,26 +48,32 @@ const DocumentVersionHistoryPage = ({ navigate(routes.PATIENT_DOCUMENTS); return; } + const fetchVersionHistory = async (): Promise => { - try { - const response = await getDocumentVersionHistoryResponse({ - nhsNumber, - baseUrl, - baseHeaders, - documentReferenceId: documentReference.id, - }); - setVersionHistory(response); - } catch { - navigate(routes.PATIENT_DOCUMENTS); - } finally { - setLoading(false); + if (!versionHistoryRef.current) { + versionHistoryRef.current = true; + try { + const response = await getDocumentVersionHistoryResponse({ + nhsNumber, + baseUrl, + baseHeaders, + documentReferenceId: documentReference.id, + }); + setVersionHistory(response); + } catch { + navigate(routes.PATIENT_DOCUMENTS); + } finally { + setLoading(false); + } } }; void fetchVersionHistory(); }, [documentReference, nhsNumber, baseUrl, baseHeaders, navigate]); + if (loading) { return ; } + if (!documentReference) { navigate(routes.PATIENT_DOCUMENTS); return <>; @@ -77,9 +84,13 @@ const DocumentVersionHistoryPage = ({ return

    No version history available for this document.

    ; } + const sortedEntries = [...versionHistory.entry].sort( + (a, b) => Number(getVersionId(b.resource)) - Number(getVersionId(a.resource)), + ); + return ( - {versionHistory.entry.map((entry, index) => { + {sortedEntries.map((entry, index) => { const isCurrentVersion = index === 0; const status = isCurrentVersion ? TimelineStatus.Active From eac5722b203264c7f3703fc8f7828d399ed5586a Mon Sep 17 00:00:00 2001 From: Lillie Dae Date: Thu, 19 Mar 2026 17:01:00 +0000 Subject: [PATCH 4/4] getAuthorValue --- app/src/helpers/utils/fhirUtil.ts | 9 +++++++++ .../DocumentVersionHistoryPage.tsx | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/helpers/utils/fhirUtil.ts b/app/src/helpers/utils/fhirUtil.ts index 05bf5525b6..e315f014be 100644 --- a/app/src/helpers/utils/fhirUtil.ts +++ b/app/src/helpers/utils/fhirUtil.ts @@ -15,3 +15,12 @@ export const getCreatedDate = (doc: FhirDocumentReference): string => doc.date ? */ export const getCustodianValue = (doc: FhirDocumentReference): string => doc.custodian?.identifier?.value ?? doc.custodian?.display ?? ''; + +/** + * Gets the author from a FHIR R4 DocumentReference + */ +export const getAuthorValue = (doc: FhirDocumentReference): string => + doc.author?.[0]?.identifier?.value ?? + doc.author?.[0]?.display ?? + doc.author?.[0]?.reference ?? + ''; diff --git a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx index f7fb312e04..3b20998d80 100644 --- a/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx +++ b/app/src/pages/documentVersionHistoryPage/DocumentVersionHistoryPage.tsx @@ -11,7 +11,7 @@ import usePatient from '../../helpers/hooks/usePatient'; import useTitle from '../../helpers/hooks/useTitle'; import { getDocumentVersionHistoryResponse } from '../../helpers/requests/getDocumentVersionHistory'; import { getDocumentTypeLabel } from '../../helpers/utils/documentType'; -import { getCreatedDate, getCustodianValue, getVersionId } from '../../helpers/utils/fhirUtil'; +import { getAuthorValue, getCreatedDate, getVersionId } from '../../helpers/utils/fhirUtil'; import { getFormatDateWithAtTime } from '../../helpers/utils/formatDate'; import { Bundle } from '../../types/fhirR4/bundle'; import { FhirDocumentReference } from '../../types/fhirR4/documentReference'; @@ -121,7 +121,7 @@ const DocumentVersionHistoryPage = ({