Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
01c53ac
CCM-12890: add copy to clipboard button
bhansell1 Jan 21, 2026
766d1e2
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Jan 21, 2026
fc2b369
CCM-12890: simplify copying
bhansell1 Jan 23, 2026
7d18d61
CCM-12890: move test from next to code location to __test__ location …
bhansell1 Jan 23, 2026
85d16b2
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Jan 23, 2026
6959dd9
CCM-12890: self-review
bhansell1 Jan 23, 2026
22272a7
CCM-12890: add error state when failing to copy to clipboard
bhansell1 Jan 28, 2026
b1bb89d
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Jan 28, 2026
f3922aa
CCM-12890: add component test
bhansell1 Jan 28, 2026
7674d73
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Jan 29, 2026
8160fb2
CCM-12890: fix automated test
bhansell1 Jan 29, 2026
7fb0919
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Jan 29, 2026
ccb0edc
Merge branch 'main' into feature/CCM-12890_copy-to-clipboard
bhansell1 Jan 29, 2026
e10a9f2
CCM-12890: fix grammar
bhansell1 Jan 30, 2026
65fbd44
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Jan 30, 2026
ea7566e
Merge branch 'feature/CCM-12890_copy-to-clipboard' of https://github.…
bhansell1 Jan 30, 2026
dc7ce0c
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Jan 30, 2026
d0b723d
CCM-12890: clear permissions on test
bhansell1 Feb 2, 2026
4188f2d
Merge branch 'main' of https://github.com/NHSDigital/nhs-notify-web-t…
bhansell1 Feb 2, 2026
aec1458
CCM-12890: linting
bhansell1 Feb 2, 2026
0d98303
Merge branch 'main' into feature/CCM-12890_copy-to-clipboard
bhansell1 Feb 2, 2026
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"',
'"<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
Loading