Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
@@ -1,5 +1,9 @@
import { render, screen } from '@testing-library/react';
import { MessagePlansList } from '@molecules/MessagePlansList/MessagePlansList';
import { fireEvent, render, screen } from '@testing-library/react';
import {
MessagePlanListItem,
MessagePlansList,
} from '@molecules/MessagePlansList/MessagePlansList';
import userEvent from '@testing-library/user-event';

describe('MessagePlansList', () => {
it('matches snapshot when data is available', async () => {
Expand Down Expand Up @@ -60,6 +64,87 @@ describe('MessagePlansList', () => {
expect(lastEditedCell).toHaveTextContent('13:00');
});

it('should copy message plan names and IDs to clipboard when button is clicked', async () => {
const mockPlans: MessagePlanListItem[] = [
{ name: 'Plan 1', id: 'id-1', lastUpdated: '2026-01-23T10:00:00Z' },
{ name: 'Plan 2', id: 'id-2', lastUpdated: '2026-01-23T11:00:00Z' },
];

const mockClipboardWrite = jest.fn().mockResolvedValue(undefined);

Object.defineProperty(navigator, 'clipboard', {
value: { write: mockClipboardWrite },
writable: true,
configurable: true,
});

global.ClipboardItem = jest.fn(
(data) => data
) as unknown as typeof ClipboardItem;

const { getByTestId } = render(
<MessagePlansList status='DRAFT' count={2} plans={mockPlans} />
);

const expander = getByTestId('message-plans-list-draft');
fireEvent.click(expander);

const copyButton = getByTestId('copy-button-draft');

await userEvent.click(copyButton);

expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
expect(copyButton).toHaveTextContent('Names and IDs copied to clipboard');

const [clipboardItem] = mockClipboardWrite.mock.calls[0][0];

const csv = clipboardItem['text/plain'];

const expectedCSV = [
'routing_plan_name,routing_plan_id',
'"Plan 1","id-1"',
'"Plan 2","id-2"',
].join('\n');

expect(csv).toEqual(expectedCSV);
});

it('should display error message when clipboard write fails', async () => {
const mockPlans: MessagePlanListItem[] = [
{ name: 'Plan 1', id: 'id-1', lastUpdated: '2026-01-23T10:00:00Z' },
];

const mockClipboardWrite = jest
.fn()
.mockRejectedValue(new Error('Permission denied'));

Object.defineProperty(navigator, 'clipboard', {
value: { write: mockClipboardWrite },
writable: true,
configurable: true,
});

global.ClipboardItem = jest.fn(
(data) => data
) as unknown as typeof ClipboardItem;

const { getByTestId } = render(
<MessagePlansList status='DRAFT' count={1} plans={mockPlans} />
);

const expander = getByTestId('message-plans-list-draft');
fireEvent.click(expander);

const copyButton = getByTestId('copy-button-draft');

await userEvent.click(copyButton);

expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
expect(copyButton).toHaveTextContent(
'Failed copying names and IDs to clipboard'
);
});

it('matches snapshot when data is available - COMPLETED', async () => {
const data = {
count: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ exports[`MessagePlansList matches snapshot when data is available - COMPLETED 1`
</tr>
</tbody>
</table>
<button
aria-disabled="false"
class="nhsuk-button nhsuk-button--secondary"
data-testid="copy-button-production"
type="button"
>
Copy names and IDs to clipboard
</button>
</div>
</details>
</DocumentFragment>
Expand Down Expand Up @@ -207,6 +215,14 @@ exports[`MessagePlansList matches snapshot when data is available 1`] = `
</tr>
</tbody>
</table>
<button
aria-disabled="false"
class="nhsuk-button nhsuk-button--secondary"
data-testid="copy-button-draft"
type="button"
>
Copy names and IDs to clipboard
</button>
</div>
</details>
</DocumentFragment>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { renderHook, act } from '@testing-library/react';
import { useCopyTableToClipboard } from '../../hooks/use-copy-table-to-clipboard.hook';

type TestData = {
name: string;
id: string;
value: string;
};

describe('useCopyTableToClipboard', () => {
let mockClipboardWrite: jest.Mock;

beforeEach(() => {
mockClipboardWrite = jest.fn().mockResolvedValue(undefined);

Object.defineProperty(navigator, 'clipboard', {
value: { write: mockClipboardWrite },
writable: true,
configurable: true,
});

global.ClipboardItem = jest.fn(
(data) => data
) as unknown as typeof ClipboardItem;

jest.useFakeTimers();
});

afterEach(() => {
act(() => {
jest.runOnlyPendingTimers();
});
jest.useRealTimers();
jest.clearAllMocks();
});

it('should copy data in both CSV and HTML formats to clipboard', async () => {
const { result } = renderHook(() => useCopyTableToClipboard<TestData>());

const testData: TestData[] = [
{ name: 'Test "quoted" value', id: 'id-1', value: '100' },
{ name: '<template test name>', id: 'id & value', value: '200' },
];

await act(async () => {
await result.current.copyToClipboard({
data: testData,
columns: [
{ key: 'name', header: 'Name' },
{ key: 'id', header: 'ID' },
],
});
});

expect(mockClipboardWrite).toHaveBeenCalledTimes(1);

const [clipboardItem] = mockClipboardWrite.mock.calls[0][0];
const csv = clipboardItem['text/plain'];
const html = clipboardItem['text/html'];

const expectedCSV = [
'Name,ID',
'"Test ""quoted"" value","id-1"',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this double quotes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSV standard is to escape double quotes by using another quote.

'"<template test name>","id & value"',
].join('\n');

expect(csv).toEqual(expectedCSV);

const expectedHTML = `
<table>
<thead>
<tr>
<th>Name</th>
<th>ID</th>
</tr>
</thead>
<tbody>
<tr>
<td>Test &quot;quoted&quot; value</td>
<td>id-1</td>
</tr>
<tr>
<td>&lt;template test name&gt;</td>
<td>id &amp; value</td>
</tr>
</tbody>
</table>`
.replaceAll(/>\s+</g, '><')
.trim();

expect(html).toEqual(expectedHTML);

expect(result.current.copied).toBe(true);
expect(result.current.copyError).toBeNull();

act(() => {
jest.advanceTimersByTime(5000);
});

expect(result.current.copied).toBe(false);
});

it('should handle clipboard write failures', async () => {
mockClipboardWrite.mockRejectedValueOnce(new Error('Permission denied'));

const { result } = renderHook(() => useCopyTableToClipboard<TestData>());

await act(async () => {
await result.current.copyToClipboard({
data: [{ name: 'Test', id: 'id-1', value: '100' }],
columns: [{ key: 'name', header: 'Name' }],
});
});

expect(result.current.copyError).toEqual(new Error('Permission denied'));
expect(result.current.copied).toBe(false);

act(() => {
jest.advanceTimersByTime(5000);
});

expect(result.current.copyError).toBeNull();

mockClipboardWrite.mockResolvedValueOnce(undefined);

await act(async () => {
await result.current.copyToClipboard({
data: [{ name: 'Test', id: 'id-1', value: '100' }],
columns: [{ key: 'name', header: 'Name' }],
});
});

expect(result.current.copyError).toBeNull();
expect(result.current.copied).toBe(true);
});

it('should clear previous timeout when copying multiple times', async () => {
const { result } = renderHook(() => useCopyTableToClipboard<TestData>());

const testData: TestData[] = [{ name: 'Test 1', id: 'id-1', value: '100' }];

await act(async () => {
await result.current.copyToClipboard({
data: testData,
columns: [{ key: 'name', header: 'Name' }],
});
});

expect(result.current.copied).toBe(true);

act(() => {
jest.advanceTimersByTime(2500);
});

expect(result.current.copied).toBe(true);

await act(async () => {
await result.current.copyToClipboard({
data: testData,
columns: [{ key: 'name', header: 'Name' }],
});
});

expect(result.current.copied).toBe(true);

act(() => {
jest.advanceTimersByTime(2500);
});

expect(result.current.copied).toBe(true);

act(() => {
jest.advanceTimersByTime(2500);
});

expect(result.current.copied).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import classNames from 'classnames';
import content from '@content/content';
import { Details, Table } from 'nhsuk-react-components';
import { Button, Details, Table } from 'nhsuk-react-components';
import { format } from 'date-fns/format';
import Link from 'next/link';
import { MarkdownContent } from '@molecules/MarkdownContent/MarkdownContent';
import type { RoutingConfigStatusActive } from 'nhs-notify-backend-client';
import { messagePlanStatusToDisplayText } from 'nhs-notify-web-template-management-utils';
import { interpolate } from '@utils/interpolate';
import { useCopyTableToClipboard } from '@hooks/use-copy-table-to-clipboard.hook';

export type MessagePlanListItem = {
name: string;
Expand All @@ -30,6 +31,19 @@ export const MessagePlansList = (props: MessagePlansListProps) => {
const { status, count } = props;
const statusDisplayMapping = messagePlanStatusToDisplayText(status);
const statusDisplayLower = statusDisplayMapping.toLowerCase();
const { copyToClipboard, copied, copyError } =
useCopyTableToClipboard<MessagePlanListItem>();

const handleCopyToClipboard = async () => {
await copyToClipboard({
data: props.plans,
columns: [
{ key: 'name', header: 'routing_plan_name' },
{ key: 'id', header: 'routing_plan_id' },
],
});
};

const messagePlanLink = messagePlansListComponent.messagePlanLink[status];

const header = (
Expand Down Expand Up @@ -60,6 +74,14 @@ export const MessagePlansList = (props: MessagePlansListProps) => {
</Table.Row>
));

let copyButtonText = messagePlansListComponent.copyText;

if (copied) {
copyButtonText = messagePlansListComponent.copiedText;
} else if (copyError) {
copyButtonText = messagePlansListComponent.copiedFailedText;
}

return (
<Details expander data-testid={`message-plans-list-${statusDisplayLower}`}>
<Details.Summary
Expand All @@ -69,10 +91,20 @@ export const MessagePlansList = (props: MessagePlansListProps) => {
</Details.Summary>
<Details.Text>
{rows.length > 0 ? (
<Table responsive>
<Table.Head role='rowgroup'>{header}</Table.Head>
<Table.Body>{rows}</Table.Body>
</Table>
<>
<Table responsive>
<Table.Head role='rowgroup'>{header}</Table.Head>
<Table.Body>{rows}</Table.Body>
</Table>
<Button
type='button'
data-testid={`copy-button-${statusDisplayLower}`}
secondary
onClick={handleCopyToClipboard}
>
{copyButtonText}
</Button>
</>
) : (
<MarkdownContent
content={messagePlansListComponent.noMessagePlansMessage}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/content/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,9 @@ const messagePlanGetReadyToMoveToProduction = () => {
const messagePlansListComponent = {
tableHeadings: ['Name', 'Routing Plan ID', 'Last edited'],
noMessagePlansMessage: 'You do not have any message plans in {{status}} yet.',
copyText: 'Copy names and IDs to clipboard',
copiedText: 'Names and IDs copied to clipboard',
copiedFailedText: 'Failed copying names and IDs to clipboard',
messagePlanLink: {
DRAFT: '/message-plans/choose-templates/{{routingConfigId}}',
COMPLETED: '/message-plans/preview-message-plan/{{routingConfigId}}',
Expand Down
Loading