Skip to content

Commit 6818542

Browse files
fix: Copy to clipboard would seemingly fail even if it worked
1 parent a522c48 commit 6818542

18 files changed

Lines changed: 50 additions & 103 deletions

File tree

src/course-outline/CourseOutline.test.jsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,6 @@ const courseId = '123';
6868

6969
window.HTMLElement.prototype.scrollIntoView = jest.fn();
7070

71-
const clipboardBroadcastChannelMock = {
72-
postMessage: jest.fn(),
73-
close: jest.fn(),
74-
};
75-
76-
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
77-
7871
jest.mock('react-router-dom', () => ({
7972
...jest.requireActual('react-router-dom'),
8073
useLocation: jest.fn(),

src/course-outline/subsection-card/SubsectionCard.test.jsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,6 @@ jest.mock('react-router-dom', () => ({
2121
}),
2222
}));
2323

24-
const clipboardBroadcastChannelMock = {
25-
postMessage: jest.fn(),
26-
close: jest.fn(),
27-
};
28-
29-
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
30-
3124
const unit = {
3225
id: 'unit-1',
3326
};

src/course-outline/unit-card/UnitCard.test.jsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,6 @@ const unit = {
4747

4848
const queryClient = new QueryClient();
4949

50-
const clipboardBroadcastChannelMock = {
51-
postMessage: jest.fn(),
52-
close: jest.fn(),
53-
};
54-
55-
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
56-
5750
const renderComponent = (props) => render(
5851
<AppProvider store={store}>
5952
<QueryClientProvider client={queryClient}>

src/course-unit/CourseUnit.test.jsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,6 @@ jest.mock('react-router-dom', () => ({
9393
useNavigate: () => mockedUsedNavigate,
9494
}));
9595

96-
const clipboardBroadcastChannelMock = {
97-
postMessage: jest.fn(),
98-
close: jest.fn(),
99-
};
100-
101-
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
102-
10396
/**
10497
* Simulates receiving a post message event for testing purposes.
10598
* This can be used to mimic events like deletion or other actions

src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,6 @@ let axiosMock;
2222
let queryClient;
2323
const courseId = '123';
2424

25-
const clipboardBroadcastChannelMock = {
26-
postMessage: jest.fn(),
27-
close: jest.fn(),
28-
};
29-
30-
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
31-
3225
const renderComponent = (props = {}) => render(
3326
<AppProvider store={store}>
3427
<IntlProvider locale="en">

src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
66
import { Provider } from 'react-redux';
77

88
import { messageTypes } from '../../../constants';
9-
import { mockBroadcastChannel } from '../../../../generic/data/api.mock';
109
import initializeStore from '../../../../store';
1110
import { useMessageHandlers } from '..';
1211

@@ -20,8 +19,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({
2019
logError: jest.fn(),
2120
}));
2221

23-
mockBroadcastChannel();
24-
2522
describe('useMessageHandlers', () => {
2623
let handlers;
2724
let result;

src/generic/clipboard/hooks/useClipboard.test.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '../../../__mocks__';
88
import { initializeMocks, makeWrapper } from '../../../testUtils';
99
import { getClipboardUrl } from '../../data/api';
10-
import useClipboard from './useClipboard';
10+
import useClipboard, { _testingOverrideBroadcastChannel } from './useClipboard';
1111

1212
initializeMocks();
1313

@@ -16,13 +16,14 @@ let mockShowToast: jest.Mock;
1616

1717
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
1818
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
19+
20+
let broadcastMockListener: (x: unknown) => void | undefined;
1921
const clipboardBroadcastChannelMock = {
20-
postMessage: jest.fn(),
21-
close: jest.fn(),
22-
onmessage: jest.fn(),
22+
postMessage: (message: unknown) => { broadcastMockListener(message); },
23+
addEventListener: (_eventName: string, handler: typeof broadcastMockListener) => { broadcastMockListener = handler; },
24+
removeEventListener: jest.fn(),
2325
};
24-
25-
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
26+
_testingOverrideBroadcastChannel(clipboardBroadcastChannelMock as any);
2627

2728
describe('useClipboard', () => {
2829
beforeEach(async () => {
@@ -88,14 +89,14 @@ describe('useClipboard', () => {
8889
describe('broadcast channel message handling', () => {
8990
it('updates states correctly on receiving a broadcast message', async () => {
9091
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
91-
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
92+
clipboardBroadcastChannelMock.postMessage({ data: clipboardUnit });
9293

9394
rerender();
9495

9596
expect(result.current.showPasteUnit).toBe(true);
9697
expect(result.current.showPasteXBlock).toBe(false);
9798

98-
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
99+
clipboardBroadcastChannelMock.postMessage({ data: clipboardXBlock });
99100
rerender();
100101

101102
expect(result.current.showPasteUnit).toBe(false);

src/generic/clipboard/hooks/useClipboard.ts

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useIntl } from '@edx/frontend-platform/i18n';
22
import { useQuery, useQueryClient } from '@tanstack/react-query';
3-
import { useContext, useEffect, useState } from 'react';
3+
import { useCallback, useContext, useEffect } from 'react';
44

55
import { getClipboard, updateClipboard } from '../../data/api';
66
import {
@@ -11,6 +11,14 @@ import {
1111
import { ToastContext } from '../../toast-context';
1212
import messages from './messages';
1313

14+
// Global, shared broadcast channel for the clipboard. Disabled by default in test environment where it's not defined.
15+
let clipboardBroadcastChannel = (
16+
typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL) : null
17+
);
18+
/** To allow mocking the broadcast channel for testing */
19+
// eslint-disable-next-line
20+
export const _testingOverrideBroadcastChannel = (x: BroadcastChannel) => { clipboardBroadcastChannel = x; };
21+
1422
/**
1523
* Custom React hook for managing clipboard functionality.
1624
*
@@ -23,7 +31,6 @@ import messages from './messages';
2331
*/
2432
const useClipboard = (canEdit: boolean = true) => {
2533
const intl = useIntl();
26-
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
2734
const { data: clipboardData } = useQuery({
2835
queryKey: ['clipboard'],
2936
queryFn: getClipboard,
@@ -33,37 +40,48 @@ const useClipboard = (canEdit: boolean = true) => {
3340

3441
const queryClient = useQueryClient();
3542

36-
const copyToClipboard = async (usageKey: string) => {
43+
const copyToClipboard = useCallback(async (usageKey: string) => {
3744
// This code is synchronous for now, but it could be made asynchronous in the future.
3845
// In that case, the `done` message should be shown after the asynchronous operation completes.
3946
showToast(intl.formatMessage(messages.copying));
47+
let newData;
4048
try {
41-
const newData = await updateClipboard(usageKey);
42-
clipboardBroadcastChannel.postMessage(newData);
49+
newData = await updateClipboard(usageKey);
4350
queryClient.setQueryData(['clipboard'], newData);
44-
showToast(intl.formatMessage(messages.done));
4551
} catch (error) {
4652
showToast(intl.formatMessage(messages.error));
53+
return;
4754
}
48-
};
55+
// Update the clipboard state across all other open browser tabs too:
56+
try {
57+
clipboardBroadcastChannel?.postMessage(newData);
58+
} catch (error) {
59+
// Log the error but no need to show it to the user.
60+
// istanbul ignore next
61+
// eslint-disable-next-line no-console
62+
console.error('Unable to sync clipboard state across other open tabs:', error);
63+
}
64+
showToast(intl.formatMessage(messages.done));
65+
}, [showToast, intl, queryClient]);
66+
67+
const handleBroadcastMessage = useCallback((event: MessageEvent) => {
68+
// Note: if this useClipboard() hook is used many times on one page,
69+
// this will result in many separate calls to setQueryData() whenever
70+
// the clipboard contents change, but that is fine and shouldn't actually
71+
// cause any issues. If it did, we could refactor this into a
72+
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
73+
// rather than having a separate channel per useClipboard hook.
74+
queryClient.setQueryData(['clipboard'], event.data);
75+
}, [queryClient]);
4976

5077
useEffect(() => {
5178
// Handle messages from the broadcast channel
52-
clipboardBroadcastChannel.onmessage = (event) => {
53-
// Note: if this useClipboard() hook is used many times on one page,
54-
// this will result in many separate calls to setQueryData() whenever
55-
// the clipboard contents change, but that is fine and shouldn't actually
56-
// cause any issues. If it did, we could refactor this into a
57-
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
58-
// rather than having a separate channel per useClipboard hook.
59-
queryClient.setQueryData(['clipboard'], event.data);
60-
};
61-
79+
clipboardBroadcastChannel?.addEventListener('message', handleBroadcastMessage);
6280
// Cleanup function for the BroadcastChannel when the hook is unmounted
6381
return () => {
64-
clipboardBroadcastChannel.close();
82+
clipboardBroadcastChannel?.removeEventListener('message', handleBroadcastMessage);
6583
};
66-
}, [clipboardBroadcastChannel]);
84+
}, []);
6785

6886
const isPasteable = canEdit && clipboardData?.content?.status !== CLIPBOARD_STATUS.expired;
6987
const showPasteUnit = isPasteable && clipboardData?.content?.blockType === 'vertical';

src/generic/data/api.mock.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,3 @@ export async function mockClipboardHtml(blockType?: string): Promise<api.Clipboa
3838
}
3939
mockClipboardHtml.applyMock = (blockType?: string) => jest.spyOn(api, 'getClipboard').mockImplementation(() => mockClipboardHtml(blockType));
4040
mockClipboardHtml.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardHtml);
41-
42-
/** Mock the DOM `BroadcastChannel` API which the clipboard code uses */
43-
export function mockBroadcastChannel() {
44-
const clipboardBroadcastChannelMock = {
45-
postMessage: jest.fn(),
46-
close: jest.fn(),
47-
};
48-
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
49-
}

src/generic/hooks/tests/hooks.test.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { getConfig } from '@edx/frontend-platform';
33
import { act, renderHook } from '@testing-library/react';
44
import { useKeyedState } from '@edx/react-unit-test-utils';
55
import { logError } from '@edx/frontend-platform/logging';
6-
import { mockBroadcastChannel } from '../../data/api.mock';
76
import { iframeMessageTypes, iframeStateKeys } from '../../../constants';
87
import { useIframeBehavior } from '../useIframeBehavior';
98
import { useLoadBearingHook } from '../useLoadBearingHook';
@@ -18,8 +17,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({
1817
logError: jest.fn(),
1918
}));
2019

21-
mockBroadcastChannel();
22-
2320
describe('useIframeBehavior', () => {
2421
const id = 'test-id';
2522
const iframeUrl = 'http://example.com';

0 commit comments

Comments
 (0)