Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,7 @@ describe('DocumentSelectStage', () => {
documentConfig: config,
});

await userEvent.upload(screen.getByTestId('button-input'), [
buildLgFile(1),
]);
await userEvent.upload(screen.getByTestId('button-input'), [buildLgFile(1)]);

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

Expand Down Expand Up @@ -263,8 +261,8 @@ describe('DocumentSelectStage', () => {

await waitFor(() => {
expect(mockedUseNavigate).toHaveBeenCalledWith({
"pathname": routeChildren.DOCUMENT_UPLOAD_SELECT_ORDER,
"search": "",
pathname: routeChildren.DOCUMENT_UPLOAD_SELECT_ORDER,
search: '',
});
});
});
Expand All @@ -283,8 +281,8 @@ describe('DocumentSelectStage', () => {

await waitFor(() => {
expect(mockedUseNavigate).toHaveBeenCalledWith({
"pathname": routeChildren.DOCUMENT_UPLOAD_CONFIRMATION,
"search": "",
pathname: routeChildren.DOCUMENT_UPLOAD_CONFIRMATION,
search: '',
});
});
});
Expand Down Expand Up @@ -566,7 +564,13 @@ describe('DocumentSelectStage', () => {
documentConfig?: DOCUMENT_TYPE_CONFIG;
};

const TestApp = ({ goToPreviousDocType, goToNextDocType, backLinkOverride, showSkipLink, documentConfig }: TestAppProps): JSX.Element => {
const TestApp = ({
goToPreviousDocType,
goToNextDocType,
backLinkOverride,
showSkipLink,
documentConfig,
}: TestAppProps): JSX.Element => {
const [documents, setDocuments] = useState<Array<UploadDocument>>([]);
const filesErrorRef = useRef<boolean>(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ErrorMessageListItem } from '../../../../types/pages/genericPageErrors'
import { getJourney, useEnhancedNavigate } from '../../../../helpers/utils/urlManipulations';
import { DOCUMENT_TYPE, DOCUMENT_TYPE_CONFIG } from '../../../../helpers/utils/documentType';
import rejectedFileTypes from '../../../../config/rejectedFileTypes.json';
import parseTextWithLinks from '../../../../helpers/utils/parseTextWithLinks';

export type Props = {
setDocuments: SetUploadDocuments;
Expand Down Expand Up @@ -253,7 +254,7 @@ const DocumentSelectStage = ({

const continueClicked = (): void => {
resetErrors();

if (!validateDocuments()) {
scrollToRef.current?.scrollIntoView({ behavior: 'smooth' });
return;
Expand All @@ -273,7 +274,7 @@ const DocumentSelectStage = ({
};

const skipClicked = (checkDocCount: boolean = true): void => {
if (checkDocCount && documents.some(doc => doc.docType === documentType)) {
if (checkDocCount && documents.some((doc) => doc.docType === documentType)) {
resetErrors();
setRemoveFilesToSkip(true);
setTimeout(() => {
Expand Down Expand Up @@ -392,7 +393,10 @@ const DocumentSelectStage = ({
Go back
</BackLink>

{(errorDocs().length > 0 || noFilesSelected || tooManyFilesAdded || removeFilesToSkip) && (
{(errorDocs().length > 0 ||
noFilesSelected ||
tooManyFilesAdded ||
removeFilesToSkip) && (
<ErrorBox
dataTestId="error-box"
errorBoxSummaryId="failed-document-uploads-summary-title"
Expand Down Expand Up @@ -420,7 +424,7 @@ const DocumentSelectStage = ({
{([] as string[])
.concat(documentConfig.content.chooseFilesWarningText)
.map((text) => (
<p key={text}>{text}</p>
<p key={text}>{parseTextWithLinks(text)}</p>
))}
</WarningCallout>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
],
"chooseFilesMessage": "Choose files to upload",
"chooseFilesButtonLabel": "Choose files",
"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.",
"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.",
"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."
],
"confirmFilesTitle": "Check files are for the correct patient",
"confirmFilesTableTitle": "Attachments to this EHR to upload",
"confirmFilesTableParagraph": "You can upload files in any format but you can only view PDF files in this service.",
Expand Down
2 changes: 1 addition & 1 deletion app/src/config/electronicHealthRecordConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"chooseFilesButtonLabel": "Choose PDF file",
"chooseFilesWarningText": [
"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'.",
"They are downloaded as a single file from the clinical system.",
"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.",
"The file does not include attachments such as letters or other documents. You'll be asked to upload those separately in the next step."
],
"confirmFilesTitle": "Check file is for the correct patient",
Expand Down
145 changes: 145 additions & 0 deletions app/src/helpers/utils/parseTextWithLinks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import parseTextWithLinks from './parseTextWithLinks';

describe('parseTextWithLinks', () => {
describe('Basic functionality', () => {
it('parses text with a single markdown link', () => {
const text = 'Click [here](https://example.com) to continue';
const result = render(parseTextWithLinks(text));

const link = result.container.querySelector('a');
expect(link).not.toBeNull();
expect(link?.href).toBe('https://example.com/');
expect(link?.textContent).toBe('here');
expect(link?.target).toBe('_blank');
expect(link?.rel).toBe('noreferrer');
expect(link?.getAttribute('aria-label')).toBe(
'here - this link will open in a new tab',
);
});

it('parses text with multiple markdown links', () => {
const text = 'Visit [Home](https://home.com) or [NHS](https://nhs.uk) for info';
const result = render(parseTextWithLinks(text));

const links = result.container.querySelectorAll('a');
expect(links).toHaveLength(2);

expect(links[0]?.href).toBe('https://home.com/');
expect(links[0]?.textContent).toBe('Home');

expect(links[1]?.href).toBe('https://nhs.uk/');
expect(links[1]?.textContent).toBe('NHS');
});

it('preserves text before, between, and after links', () => {
const text = 'Start [link1](url1) middle [link2](url2) end';
const result = render(parseTextWithLinks(text));

expect(result.container.textContent).toBe('Start link1 middle link2 end');
});

it('returns plain text when no markdown links are present', () => {
const text = 'This is plain text without any links';
const result = render(parseTextWithLinks(text));

expect(result.container.querySelector('a')).toBeNull();
expect(result.container.textContent).toBe(text);
});
});

describe('Edge cases', () => {
it('handles empty string', () => {
const text = '';
const result = render(parseTextWithLinks(text));

expect(result.container.textContent).toBe('');
expect(result.container.querySelector('a')).toBeNull();
});

it('handles text with only a markdown link', () => {
const text = '[Click here](https://example.com)';
const result = render(parseTextWithLinks(text));

const link = result.container.querySelector('a');
expect(link).not.toBeNull();
expect(link?.textContent).toBe('Click here');
expect(result.container.textContent).toBe('Click here');
});

it('handles consecutive markdown links', () => {
const text = '[Link1](url1)[Link2](url2)';
const result = render(parseTextWithLinks(text));

const links = result.container.querySelectorAll('a');
expect(links).toHaveLength(2);
expect(result.container.textContent).toBe('Link1Link2');
});

it('handles markdown link at the start', () => {
const text = '[Start link](url) followed by text';
const result = render(parseTextWithLinks(text));

expect(result.container.textContent).toBe('Start link followed by text');
expect(result.container.querySelector('a')?.textContent).toBe('Start link');
});

it('handles markdown link at the end', () => {
const text = 'Text followed by [end link](url)';
const result = render(parseTextWithLinks(text));

expect(result.container.textContent).toBe('Text followed by end link');
expect(result.container.querySelector('a')?.textContent).toBe('end link');
});

it('handles special characters in URL', () => {
const text = 'Visit [NHS](https://nhs.uk/conditions?query=test&page=1)';
const result = render(parseTextWithLinks(text));

const link = result.container.querySelector('a');
expect(link?.href).toBe('https://nhs.uk/conditions?query=test&page=1');
});

it('handles URLs with fragments', () => {
const text = 'Go to [section](https://example.com#section-1)';
const result = render(parseTextWithLinks(text));

const link = result.container.querySelector('a');
expect(link?.href).toBe('https://example.com/#section-1');
});

it('handles relative URLs', () => {
const text = 'See [documentation](./docs/readme.md)';
const result = render(parseTextWithLinks(text));

const link = result.container.querySelector('a');
expect(link).not.toBeNull();
expect(link?.textContent).toBe('documentation');
});

it('does not parse incomplete markdown links - missing closing bracket', () => {
const text = 'This is [incomplete link(url) text';
const result = render(parseTextWithLinks(text));

expect(result.container.querySelector('a')).toBeNull();
expect(result.container.textContent).toBe(text);
});

it('does not parse incomplete markdown links - missing closing parenthesis', () => {
const text = 'This is [text](incomplete url text';
const result = render(parseTextWithLinks(text));

expect(result.container.querySelector('a')).toBeNull();
expect(result.container.textContent).toBe(text);
});

it('handles text inbetween text and link', () => {
const text = 'Check this link: [example] text (https://example.com)';
const result = render(parseTextWithLinks(text));

const link = result.container.querySelector('a');
expect(link).toBeNull();
});
});
});
38 changes: 38 additions & 0 deletions app/src/helpers/utils/parseTextWithLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { JSX } from 'react';

export const parseTextWithLinks = (text: string): JSX.Element => {
const markdownLinkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
const parts: (string | JSX.Element)[] = [];
let lastIndex = 0;
let match = markdownLinkRegex.exec(text);

while (match !== null) {
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}

const linkText = match[1];
const linkUrl = match[2];
parts.push(
<a
key={match.index}
href={linkUrl}
target="_blank"
rel="noreferrer"
aria-label={`${linkText} - this link will open in a new tab`}
>
{linkText}
</a>,
);

lastIndex = match.index + match[0].length;
match = markdownLinkRegex.exec(text);
}

if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return <>{parts}</>;
};

export default parseTextWithLinks;
Loading