Skip to content

Commit 5d723c3

Browse files
committed
[PRMP-1378] Add help links to file upload warnings
1 parent dcfcfd5 commit 5d723c3

File tree

6 files changed

+233
-14
lines changed

6 files changed

+233
-14
lines changed

app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,7 @@ describe('DocumentSelectStage', () => {
168168
documentConfig: config,
169169
});
170170

171-
await userEvent.upload(screen.getByTestId('button-input'), [
172-
buildLgFile(1),
173-
]);
171+
await userEvent.upload(screen.getByTestId('button-input'), [buildLgFile(1)]);
174172

175173
await userEvent.click(await screen.findByTestId('skip-link'));
176174

@@ -180,6 +178,31 @@ describe('DocumentSelectStage', () => {
180178
);
181179
});
182180
});
181+
182+
const warningTexts = docConfig.content.chooseFilesWarningText;
183+
if (warningTexts) {
184+
expect(screen.getByText('Important')).toBeInTheDocument();
185+
const textsArray = ([] as string[]).concat(warningTexts);
186+
textsArray.forEach((text) => {
187+
const hasMarkdownLink = /\[([^\]]+)]\(([^)]+)\)/.test(text);
188+
if (hasMarkdownLink) {
189+
const linkMatch = text.match(/\[([^\]]+)]\(([^)]+)\)/);
190+
if (linkMatch) {
191+
const linkText = linkMatch[1];
192+
const linkUrl = linkMatch[2];
193+
const link = screen.getByRole('link', { name: new RegExp(linkText, 'i') });
194+
expect(link).toBeInTheDocument();
195+
expect(link).toHaveAttribute('href', linkUrl);
196+
expect(link).toHaveAttribute('target', '_blank');
197+
expect(link).toHaveAttribute('rel', 'noreferrer');
198+
}
199+
} else {
200+
const p = screen.getByText(text);
201+
expect(p.tagName.toLowerCase()).toBe('p');
202+
expect(screen.getByText(text)).toBeInTheDocument();
203+
}
204+
});
205+
}
183206
});
184207

185208
describe('Navigation', () => {
@@ -263,8 +286,8 @@ describe('DocumentSelectStage', () => {
263286

264287
await waitFor(() => {
265288
expect(mockedUseNavigate).toHaveBeenCalledWith({
266-
"pathname": routeChildren.DOCUMENT_UPLOAD_SELECT_ORDER,
267-
"search": "",
289+
pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_ORDER,
290+
search: '',
268291
});
269292
});
270293
});
@@ -283,8 +306,8 @@ describe('DocumentSelectStage', () => {
283306

284307
await waitFor(() => {
285308
expect(mockedUseNavigate).toHaveBeenCalledWith({
286-
"pathname": routeChildren.DOCUMENT_UPLOAD_CONFIRMATION,
287-
"search": "",
309+
pathname: routeChildren.DOCUMENT_UPLOAD_CONFIRMATION,
310+
search: '',
288311
});
289312
});
290313
});
@@ -566,7 +589,13 @@ describe('DocumentSelectStage', () => {
566589
documentConfig?: DOCUMENT_TYPE_CONFIG;
567590
};
568591

569-
const TestApp = ({ goToPreviousDocType, goToNextDocType, backLinkOverride, showSkipLink, documentConfig }: TestAppProps): JSX.Element => {
592+
const TestApp = ({
593+
goToPreviousDocType,
594+
goToNextDocType,
595+
backLinkOverride,
596+
showSkipLink,
597+
documentConfig,
598+
}: TestAppProps): JSX.Element => {
570599
const [documents, setDocuments] = useState<Array<UploadDocument>>([]);
571600
const filesErrorRef = useRef<boolean>(false);
572601

app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ErrorMessageListItem } from '../../../../types/pages/genericPageErrors'
3131
import { getJourney, useEnhancedNavigate } from '../../../../helpers/utils/urlManipulations';
3232
import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType';
3333
import rejectedFileTypes from '../../../../config/rejectedFileTypes.json';
34+
import parseTextWithLinks from '../../../../helpers/utils/parseTextWithLinks';
3435

3536
export type Props = {
3637
setDocuments: SetUploadDocuments;
@@ -253,7 +254,7 @@ const DocumentSelectStage = ({
253254

254255
const continueClicked = (): void => {
255256
resetErrors();
256-
257+
257258
if (!validateDocuments()) {
258259
scrollToRef.current?.scrollIntoView({ behavior: 'smooth' });
259260
return;
@@ -273,7 +274,7 @@ const DocumentSelectStage = ({
273274
};
274275

275276
const skipClicked = (checkDocCount: boolean = true): void => {
276-
if (checkDocCount && documents.some(doc => doc.docType === documentType)) {
277+
if (checkDocCount && documents.some((doc) => doc.docType === documentType)) {
277278
resetErrors();
278279
setRemoveFilesToSkip(true);
279280
setTimeout(() => {
@@ -392,7 +393,10 @@ const DocumentSelectStage = ({
392393
Go back
393394
</BackLink>
394395

395-
{(errorDocs().length > 0 || noFilesSelected || tooManyFilesAdded || removeFilesToSkip) && (
396+
{(errorDocs().length > 0 ||
397+
noFilesSelected ||
398+
tooManyFilesAdded ||
399+
removeFilesToSkip) && (
396400
<ErrorBox
397401
dataTestId="error-box"
398402
errorBoxSummaryId="failed-document-uploads-summary-title"
@@ -420,7 +424,7 @@ const DocumentSelectStage = ({
420424
{([] as string[])
421425
.concat(documentConfig.content.chooseFilesWarningText)
422426
.map((text) => (
423-
<p key={text}>{text}</p>
427+
<p key={text}>{parseTextWithLinks(text)}</p>
424428
))}
425429
</WarningCallout>
426430
)}

app/src/config/electronicHealthRecordAttachmentsConfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
],
2323
"chooseFilesMessage": "Choose files to upload",
2424
"chooseFilesButtonLabel": "Choose files",
25-
"chooseFilesWarningText": "Electronic health record attachments are all the documents stored in the patient's EHR with the EHR notes. For example, letters, laboratory results, scans and x rays.",
25+
"chooseFilesWarningText": [
26+
"Electronic health record attachments are all the documents stored in the patient's EHR with the EHR notes. For example, letters, laboratory results, scans and x rays.",
27+
"EHR attachments must be uploaded as individual files. See [help and guidance](https://digital.nhs.uk/services/access-and-store-digital-patient-documents/help-and-guidance) for instructions on how best to do this."
28+
],
2629
"confirmFilesTitle": "Check files are for the correct patient",
2730
"confirmFilesTableTitle": "Attachments to this EHR to upload",
2831
"confirmFilesTableParagraph": "You can upload files in any format but you can only view PDF files in this service.",

app/src/config/electronicHealthRecordConfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"chooseFilesButtonLabel": "Choose PDF file",
2727
"chooseFilesWarningText": [
2828
"The electronic health record (EHR) notes contain the patient's personal details and all notes from their consultations and interactions with the practice or other healthcare providers. You may also call them the 'journal', 'practice notes' or a 'full EHR summary'.",
29-
"They are downloaded as a single file from the clinical system.",
29+
"They are downloaded as a single file from the clinical system. See [help and guidance](https://digital.nhs.uk/services/access-and-store-digital-patient-documents/help-and-guidance) for instructions on how best to do this.",
3030
"The file does not include attachments such as letters or other documents. You'll be asked to upload those separately in the next step."
3131
],
3232
"confirmFilesTitle": "Check file is for the correct patient",
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import parseTextWithLinks from './parseTextWithLinks';
4+
5+
describe('parseTextWithLinks', () => {
6+
describe('Basic functionality', () => {
7+
it('parses text with a single markdown link', () => {
8+
const text = 'Click [here](https://example.com) to continue';
9+
const result = render(parseTextWithLinks(text));
10+
11+
const link = result.container.querySelector('a');
12+
expect(link).not.toBeNull();
13+
expect(link?.href).toBe('https://example.com/');
14+
expect(link?.textContent).toBe('here');
15+
expect(link?.target).toBe('_blank');
16+
expect(link?.rel).toBe('noreferrer');
17+
expect(link?.getAttribute('aria-label')).toBe(
18+
'here - this link will open in a new tab',
19+
);
20+
});
21+
22+
it('parses text with multiple markdown links', () => {
23+
const text = 'Visit [Home](https://home.com) or [NHS](https://nhs.uk) for info';
24+
const result = render(parseTextWithLinks(text));
25+
26+
const links = result.container.querySelectorAll('a');
27+
expect(links).toHaveLength(2);
28+
29+
expect(links[0]?.href).toBe('https://home.com/');
30+
expect(links[0]?.textContent).toBe('Home');
31+
32+
expect(links[1]?.href).toBe('https://nhs.uk/');
33+
expect(links[1]?.textContent).toBe('NHS');
34+
});
35+
36+
it('preserves text before, between, and after links', () => {
37+
const text = 'Start [link1](url1) middle [link2](url2) end';
38+
const result = render(parseTextWithLinks(text));
39+
40+
expect(result.container.textContent).toBe('Start link1 middle link2 end');
41+
});
42+
43+
it('returns plain text when no markdown links are present', () => {
44+
const text = 'This is plain text without any links';
45+
const result = render(parseTextWithLinks(text));
46+
47+
expect(result.container.querySelector('a')).toBeNull();
48+
expect(result.container.textContent).toBe(text);
49+
});
50+
});
51+
52+
describe('Edge cases', () => {
53+
it('handles empty string', () => {
54+
const text = '';
55+
const result = render(parseTextWithLinks(text));
56+
57+
expect(result.container.textContent).toBe('');
58+
expect(result.container.querySelector('a')).toBeNull();
59+
});
60+
61+
it('handles text with only a markdown link', () => {
62+
const text = '[Click here](https://example.com)';
63+
const result = render(parseTextWithLinks(text));
64+
65+
const link = result.container.querySelector('a');
66+
expect(link).not.toBeNull();
67+
expect(link?.textContent).toBe('Click here');
68+
expect(result.container.textContent).toBe('Click here');
69+
});
70+
71+
it('handles consecutive markdown links', () => {
72+
const text = '[Link1](url1)[Link2](url2)';
73+
const result = render(parseTextWithLinks(text));
74+
75+
const links = result.container.querySelectorAll('a');
76+
expect(links).toHaveLength(2);
77+
expect(result.container.textContent).toBe('Link1Link2');
78+
});
79+
80+
it('handles markdown link at the start', () => {
81+
const text = '[Start link](url) followed by text';
82+
const result = render(parseTextWithLinks(text));
83+
84+
expect(result.container.textContent).toBe('Start link followed by text');
85+
expect(result.container.querySelector('a')?.textContent).toBe('Start link');
86+
});
87+
88+
it('handles markdown link at the end', () => {
89+
const text = 'Text followed by [end link](url)';
90+
const result = render(parseTextWithLinks(text));
91+
92+
expect(result.container.textContent).toBe('Text followed by end link');
93+
expect(result.container.querySelector('a')?.textContent).toBe('end link');
94+
});
95+
96+
it('handles special characters in URL', () => {
97+
const text = 'Visit [NHS](https://nhs.uk/conditions?query=test&page=1)';
98+
const result = render(parseTextWithLinks(text));
99+
100+
const link = result.container.querySelector('a');
101+
expect(link?.href).toBe('https://nhs.uk/conditions?query=test&page=1');
102+
});
103+
104+
it('handles URLs with fragments', () => {
105+
const text = 'Go to [section](https://example.com#section-1)';
106+
const result = render(parseTextWithLinks(text));
107+
108+
const link = result.container.querySelector('a');
109+
expect(link?.href).toBe('https://example.com/#section-1');
110+
});
111+
112+
it('handles relative URLs', () => {
113+
const text = 'See [documentation](./docs/readme.md)';
114+
const result = render(parseTextWithLinks(text));
115+
116+
const link = result.container.querySelector('a');
117+
expect(link).not.toBeNull();
118+
expect(link?.textContent).toBe('documentation');
119+
});
120+
121+
it('does not parse incomplete markdown links - missing closing bracket', () => {
122+
const text = 'This is [incomplete link(url) text';
123+
const result = render(parseTextWithLinks(text));
124+
125+
expect(result.container.querySelector('a')).toBeNull();
126+
expect(result.container.textContent).toBe(text);
127+
});
128+
129+
it('does not parse incomplete markdown links - missing closing parenthesis', () => {
130+
const text = 'This is [text](incomplete url text';
131+
const result = render(parseTextWithLinks(text));
132+
133+
expect(result.container.querySelector('a')).toBeNull();
134+
expect(result.container.textContent).toBe(text);
135+
});
136+
137+
it('handles text inbetween text and link', () => {
138+
const text = 'Check this link: [example] text (https://example.com)';
139+
const result = render(parseTextWithLinks(text));
140+
141+
const link = result.container.querySelector('a');
142+
expect(link).toBeNull();
143+
});
144+
});
145+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { JSX } from 'react';
2+
3+
export const parseTextWithLinks = (text: string): JSX.Element => {
4+
const markdownLinkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
5+
const parts: (string | JSX.Element)[] = [];
6+
let lastIndex = 0;
7+
let match = markdownLinkRegex.exec(text);
8+
9+
while (match !== null) {
10+
if (match.index > lastIndex) {
11+
parts.push(text.substring(lastIndex, match.index));
12+
}
13+
14+
const linkText = match[1];
15+
const linkUrl = match[2];
16+
parts.push(
17+
<a
18+
key={match.index}
19+
href={linkUrl}
20+
target="_blank"
21+
rel="noreferrer"
22+
aria-label={`${linkText} - this link will open in a new tab`}
23+
>
24+
{linkText}
25+
</a>,
26+
);
27+
28+
lastIndex = match.index + match[0].length;
29+
match = markdownLinkRegex.exec(text);
30+
}
31+
32+
if (lastIndex < text.length) {
33+
parts.push(text.substring(lastIndex));
34+
}
35+
return <>{parts}</>;
36+
};
37+
38+
export default parseTextWithLinks;

0 commit comments

Comments
 (0)