Skip to content

Commit 3d1e99e

Browse files
authored
CCM-12890: copy to clipboard (#801)
1 parent 3ef1d0a commit 3d1e99e

7 files changed

Lines changed: 531 additions & 7 deletions

File tree

frontend/src/__tests__/components/molecules/MessagePlansList.test.tsx

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { render, screen } from '@testing-library/react';
2-
import { MessagePlansList } from '@molecules/MessagePlansList/MessagePlansList';
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import {
3+
MessagePlanListItem,
4+
MessagePlansList,
5+
} from '@molecules/MessagePlansList/MessagePlansList';
6+
import userEvent from '@testing-library/user-event';
37

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

67+
it('should copy message plan names and IDs to clipboard when button is clicked', async () => {
68+
const mockPlans: MessagePlanListItem[] = [
69+
{ name: 'Plan 1', id: 'id-1', lastUpdated: '2026-01-23T10:00:00Z' },
70+
{ name: 'Plan 2', id: 'id-2', lastUpdated: '2026-01-23T11:00:00Z' },
71+
];
72+
73+
const mockClipboardWrite = jest.fn().mockResolvedValue(undefined);
74+
75+
Object.defineProperty(navigator, 'clipboard', {
76+
value: { write: mockClipboardWrite },
77+
writable: true,
78+
configurable: true,
79+
});
80+
81+
global.ClipboardItem = jest.fn(
82+
(data) => data
83+
) as unknown as typeof ClipboardItem;
84+
85+
const { getByTestId } = render(
86+
<MessagePlansList status='DRAFT' count={2} plans={mockPlans} />
87+
);
88+
89+
const expander = getByTestId('message-plans-list-draft');
90+
fireEvent.click(expander);
91+
92+
const copyButton = getByTestId('copy-button-draft');
93+
94+
await userEvent.click(copyButton);
95+
96+
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
97+
expect(copyButton).toHaveTextContent('Names and IDs copied to clipboard');
98+
99+
const [clipboardItem] = mockClipboardWrite.mock.calls[0][0];
100+
101+
const csv = clipboardItem['text/plain'];
102+
103+
const expectedCSV = [
104+
'routing_plan_name,routing_plan_id',
105+
'"Plan 1","id-1"',
106+
'"Plan 2","id-2"',
107+
].join('\n');
108+
109+
expect(csv).toEqual(expectedCSV);
110+
});
111+
112+
it('should display error message when clipboard write fails', async () => {
113+
const mockPlans: MessagePlanListItem[] = [
114+
{ name: 'Plan 1', id: 'id-1', lastUpdated: '2026-01-23T10:00:00Z' },
115+
];
116+
117+
const mockClipboardWrite = jest
118+
.fn()
119+
.mockRejectedValue(new Error('Permission denied'));
120+
121+
Object.defineProperty(navigator, 'clipboard', {
122+
value: { write: mockClipboardWrite },
123+
writable: true,
124+
configurable: true,
125+
});
126+
127+
global.ClipboardItem = jest.fn(
128+
(data) => data
129+
) as unknown as typeof ClipboardItem;
130+
131+
const { getByTestId } = render(
132+
<MessagePlansList status='DRAFT' count={1} plans={mockPlans} />
133+
);
134+
135+
const expander = getByTestId('message-plans-list-draft');
136+
fireEvent.click(expander);
137+
138+
const copyButton = getByTestId('copy-button-draft');
139+
140+
await userEvent.click(copyButton);
141+
142+
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
143+
expect(copyButton).toHaveTextContent(
144+
'Failed copying names and IDs to clipboard'
145+
);
146+
});
147+
63148
it('matches snapshot when data is available - COMPLETED', async () => {
64149
const data = {
65150
count: 1,

frontend/src/__tests__/components/molecules/__snapshots__/MessagePlansList.test.tsx.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ exports[`MessagePlansList matches snapshot when data is available - COMPLETED 1`
101101
</tr>
102102
</tbody>
103103
</table>
104+
<button
105+
aria-disabled="false"
106+
class="nhsuk-button nhsuk-button--secondary"
107+
data-testid="copy-button-production"
108+
type="button"
109+
>
110+
Copy names and IDs to clipboard
111+
</button>
104112
</div>
105113
</details>
106114
</DocumentFragment>
@@ -207,6 +215,14 @@ exports[`MessagePlansList matches snapshot when data is available 1`] = `
207215
</tr>
208216
</tbody>
209217
</table>
218+
<button
219+
aria-disabled="false"
220+
class="nhsuk-button nhsuk-button--secondary"
221+
data-testid="copy-button-draft"
222+
type="button"
223+
>
224+
Copy names and IDs to clipboard
225+
</button>
210226
</div>
211227
</details>
212228
</DocumentFragment>
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import { useCopyTableToClipboard } from '../../hooks/use-copy-table-to-clipboard.hook';
3+
4+
type TestData = {
5+
name: string;
6+
id: string;
7+
value: string;
8+
};
9+
10+
describe('useCopyTableToClipboard', () => {
11+
let mockClipboardWrite: jest.Mock;
12+
13+
beforeEach(() => {
14+
mockClipboardWrite = jest.fn().mockResolvedValue(undefined);
15+
16+
Object.defineProperty(navigator, 'clipboard', {
17+
value: { write: mockClipboardWrite },
18+
writable: true,
19+
configurable: true,
20+
});
21+
22+
global.ClipboardItem = jest.fn(
23+
(data) => data
24+
) as unknown as typeof ClipboardItem;
25+
26+
jest.useFakeTimers();
27+
});
28+
29+
afterEach(() => {
30+
act(() => {
31+
jest.runOnlyPendingTimers();
32+
});
33+
jest.useRealTimers();
34+
jest.clearAllMocks();
35+
});
36+
37+
it('should copy data in both CSV and HTML formats to clipboard', async () => {
38+
const { result } = renderHook(() => useCopyTableToClipboard<TestData>());
39+
40+
const testData: TestData[] = [
41+
{ name: 'Test "quoted" value', id: 'id-1', value: '100' },
42+
{ name: '<template test name>', id: 'id & value', value: '200' },
43+
];
44+
45+
await act(async () => {
46+
await result.current.copyToClipboard({
47+
data: testData,
48+
columns: [
49+
{ key: 'name', header: 'Name' },
50+
{ key: 'id', header: 'ID' },
51+
],
52+
});
53+
});
54+
55+
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
56+
57+
const [clipboardItem] = mockClipboardWrite.mock.calls[0][0];
58+
const csv = clipboardItem['text/plain'];
59+
const html = clipboardItem['text/html'];
60+
61+
const expectedCSV = [
62+
'Name,ID',
63+
'"Test ""quoted"" value","id-1"',
64+
'"<template test name>","id & value"',
65+
].join('\n');
66+
67+
expect(csv).toEqual(expectedCSV);
68+
69+
const expectedHTML = `
70+
<table>
71+
<thead>
72+
<tr>
73+
<th>Name</th>
74+
<th>ID</th>
75+
</tr>
76+
</thead>
77+
<tbody>
78+
<tr>
79+
<td>Test &quot;quoted&quot; value</td>
80+
<td>id-1</td>
81+
</tr>
82+
<tr>
83+
<td>&lt;template test name&gt;</td>
84+
<td>id &amp; value</td>
85+
</tr>
86+
</tbody>
87+
</table>`
88+
.replaceAll(/>\s+</g, '><')
89+
.trim();
90+
91+
expect(html).toEqual(expectedHTML);
92+
93+
expect(result.current.copied).toBe(true);
94+
expect(result.current.copyError).toBeNull();
95+
96+
act(() => {
97+
jest.advanceTimersByTime(5000);
98+
});
99+
100+
expect(result.current.copied).toBe(false);
101+
});
102+
103+
it('should handle clipboard write failures', async () => {
104+
mockClipboardWrite.mockRejectedValueOnce(new Error('Permission denied'));
105+
106+
const { result } = renderHook(() => useCopyTableToClipboard<TestData>());
107+
108+
await act(async () => {
109+
await result.current.copyToClipboard({
110+
data: [{ name: 'Test', id: 'id-1', value: '100' }],
111+
columns: [{ key: 'name', header: 'Name' }],
112+
});
113+
});
114+
115+
expect(result.current.copyError).toEqual(new Error('Permission denied'));
116+
expect(result.current.copied).toBe(false);
117+
118+
act(() => {
119+
jest.advanceTimersByTime(5000);
120+
});
121+
122+
expect(result.current.copyError).toBeNull();
123+
124+
mockClipboardWrite.mockResolvedValueOnce(undefined);
125+
126+
await act(async () => {
127+
await result.current.copyToClipboard({
128+
data: [{ name: 'Test', id: 'id-1', value: '100' }],
129+
columns: [{ key: 'name', header: 'Name' }],
130+
});
131+
});
132+
133+
expect(result.current.copyError).toBeNull();
134+
expect(result.current.copied).toBe(true);
135+
});
136+
137+
it('should clear previous timeout when copying multiple times', async () => {
138+
const { result } = renderHook(() => useCopyTableToClipboard<TestData>());
139+
140+
const testData: TestData[] = [{ name: 'Test 1', id: 'id-1', value: '100' }];
141+
142+
await act(async () => {
143+
await result.current.copyToClipboard({
144+
data: testData,
145+
columns: [{ key: 'name', header: 'Name' }],
146+
});
147+
});
148+
149+
expect(result.current.copied).toBe(true);
150+
151+
act(() => {
152+
jest.advanceTimersByTime(2500);
153+
});
154+
155+
expect(result.current.copied).toBe(true);
156+
157+
await act(async () => {
158+
await result.current.copyToClipboard({
159+
data: testData,
160+
columns: [{ key: 'name', header: 'Name' }],
161+
});
162+
});
163+
164+
expect(result.current.copied).toBe(true);
165+
166+
act(() => {
167+
jest.advanceTimersByTime(2500);
168+
});
169+
170+
expect(result.current.copied).toBe(true);
171+
172+
act(() => {
173+
jest.advanceTimersByTime(2500);
174+
});
175+
176+
expect(result.current.copied).toBe(false);
177+
});
178+
});

frontend/src/components/molecules/MessagePlansList/MessagePlansList.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

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

1314
export type MessagePlanListItem = {
1415
name: string;
@@ -30,6 +31,19 @@ export const MessagePlansList = (props: MessagePlansListProps) => {
3031
const { status, count } = props;
3132
const statusDisplayMapping = messagePlanStatusToDisplayText(status);
3233
const statusDisplayLower = statusDisplayMapping.toLowerCase();
34+
const { copyToClipboard, copied, copyError } =
35+
useCopyTableToClipboard<MessagePlanListItem>();
36+
37+
const handleCopyToClipboard = async () => {
38+
await copyToClipboard({
39+
data: props.plans,
40+
columns: [
41+
{ key: 'name', header: 'routing_plan_name' },
42+
{ key: 'id', header: 'routing_plan_id' },
43+
],
44+
});
45+
};
46+
3347
const messagePlanLink = messagePlansListComponent.messagePlanLink[status];
3448

3549
const header = (
@@ -60,6 +74,14 @@ export const MessagePlansList = (props: MessagePlansListProps) => {
6074
</Table.Row>
6175
));
6276

77+
let copyButtonText = messagePlansListComponent.copyText;
78+
79+
if (copied) {
80+
copyButtonText = messagePlansListComponent.copiedText;
81+
} else if (copyError) {
82+
copyButtonText = messagePlansListComponent.copiedFailedText;
83+
}
84+
6385
return (
6486
<Details expander data-testid={`message-plans-list-${statusDisplayLower}`}>
6587
<Details.Summary
@@ -69,10 +91,20 @@ export const MessagePlansList = (props: MessagePlansListProps) => {
6991
</Details.Summary>
7092
<Details.Text>
7193
{rows.length > 0 ? (
72-
<Table responsive>
73-
<Table.Head role='rowgroup'>{header}</Table.Head>
74-
<Table.Body>{rows}</Table.Body>
75-
</Table>
94+
<>
95+
<Table responsive>
96+
<Table.Head role='rowgroup'>{header}</Table.Head>
97+
<Table.Body>{rows}</Table.Body>
98+
</Table>
99+
<Button
100+
type='button'
101+
data-testid={`copy-button-${statusDisplayLower}`}
102+
secondary
103+
onClick={handleCopyToClipboard}
104+
>
105+
{copyButtonText}
106+
</Button>
107+
</>
76108
) : (
77109
<MarkdownContent
78110
content={messagePlansListComponent.noMessagePlansMessage}

frontend/src/content/content.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,9 @@ const messagePlanGetReadyToMoveToProduction = () => {
14071407
const messagePlansListComponent = {
14081408
tableHeadings: ['Name', 'Routing Plan ID', 'Last edited'],
14091409
noMessagePlansMessage: 'You do not have any message plans in {{status}} yet.',
1410+
copyText: 'Copy names and IDs to clipboard',
1411+
copiedText: 'Names and IDs copied to clipboard',
1412+
copiedFailedText: 'Failed copying names and IDs to clipboard',
14101413
messagePlanLink: {
14111414
DRAFT: '/message-plans/choose-templates/{{routingConfigId}}',
14121415
COMPLETED: '/message-plans/preview-message-plan/{{routingConfigId}}',

0 commit comments

Comments
 (0)