fix: Copy to clipboard would seemingly fail even if it worked

This commit is contained in:
Braden MacDonald
2025-04-09 16:37:24 -07:00
committed by Braden MacDonald
parent a522c48045
commit 681854209a
18 changed files with 51 additions and 104 deletions

View File

@@ -68,13 +68,6 @@ const courseId = '123';
window.HTMLElement.prototype.scrollIntoView = jest.fn();
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),

View File

@@ -21,13 +21,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const unit = {
id: 'unit-1',
};

View File

@@ -47,13 +47,6 @@ const unit = {
const queryClient = new QueryClient();
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const renderComponent = (props) => render(
<AppProvider store={store}>
<QueryClientProvider client={queryClient}>

View File

@@ -93,13 +93,6 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate,
}));
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
/**
* Simulates receiving a post message event for testing purposes.
* This can be used to mimic events like deletion or other actions

View File

@@ -22,13 +22,6 @@ let axiosMock;
let queryClient;
const courseId = '123';
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const renderComponent = (props = {}) => render(
<AppProvider store={store}>
<IntlProvider locale="en">

View File

@@ -6,7 +6,6 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Provider } from 'react-redux';
import { messageTypes } from '../../../constants';
import { mockBroadcastChannel } from '../../../../generic/data/api.mock';
import initializeStore from '../../../../store';
import { useMessageHandlers } from '..';
@@ -20,8 +19,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
mockBroadcastChannel();
describe('useMessageHandlers', () => {
let handlers;
let result;

View File

@@ -7,7 +7,7 @@ import {
} from '../../../__mocks__';
import { initializeMocks, makeWrapper } from '../../../testUtils';
import { getClipboardUrl } from '../../data/api';
import useClipboard from './useClipboard';
import useClipboard, { _testingOverrideBroadcastChannel } from './useClipboard';
initializeMocks();
@@ -16,13 +16,14 @@ let mockShowToast: jest.Mock;
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
onmessage: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
let broadcastMockListener: (x: unknown) => void | undefined;
const clipboardBroadcastChannelMock = {
postMessage: (message: unknown) => { broadcastMockListener(message); },
addEventListener: (_eventName: string, handler: typeof broadcastMockListener) => { broadcastMockListener = handler; },
removeEventListener: jest.fn(),
};
_testingOverrideBroadcastChannel(clipboardBroadcastChannelMock as any);
describe('useClipboard', () => {
beforeEach(async () => {
@@ -88,14 +89,14 @@ describe('useClipboard', () => {
describe('broadcast channel message handling', () => {
it('updates states correctly on receiving a broadcast message', async () => {
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
clipboardBroadcastChannelMock.postMessage({ data: clipboardUnit });
rerender();
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
clipboardBroadcastChannelMock.postMessage({ data: clipboardXBlock });
rerender();
expect(result.current.showPasteUnit).toBe(false);

View File

@@ -1,6 +1,6 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useContext, useEffect, useState } from 'react';
import { useCallback, useContext, useEffect } from 'react';
import { getClipboard, updateClipboard } from '../../data/api';
import {
@@ -11,6 +11,14 @@ import {
import { ToastContext } from '../../toast-context';
import messages from './messages';
// Global, shared broadcast channel for the clipboard. Disabled by default in test environment where it's not defined.
let clipboardBroadcastChannel = (
typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL) : null
);
/** To allow mocking the broadcast channel for testing */
// eslint-disable-next-line
export const _testingOverrideBroadcastChannel = (x: BroadcastChannel) => { clipboardBroadcastChannel = x; };
/**
* Custom React hook for managing clipboard functionality.
*
@@ -23,7 +31,6 @@ import messages from './messages';
*/
const useClipboard = (canEdit: boolean = true) => {
const intl = useIntl();
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const { data: clipboardData } = useQuery({
queryKey: ['clipboard'],
queryFn: getClipboard,
@@ -33,37 +40,48 @@ const useClipboard = (canEdit: boolean = true) => {
const queryClient = useQueryClient();
const copyToClipboard = async (usageKey: string) => {
const copyToClipboard = useCallback(async (usageKey: string) => {
// This code is synchronous for now, but it could be made asynchronous in the future.
// In that case, the `done` message should be shown after the asynchronous operation completes.
showToast(intl.formatMessage(messages.copying));
let newData;
try {
const newData = await updateClipboard(usageKey);
clipboardBroadcastChannel.postMessage(newData);
newData = await updateClipboard(usageKey);
queryClient.setQueryData(['clipboard'], newData);
showToast(intl.formatMessage(messages.done));
} catch (error) {
showToast(intl.formatMessage(messages.error));
return;
}
};
// Update the clipboard state across all other open browser tabs too:
try {
clipboardBroadcastChannel?.postMessage(newData);
} catch (error) {
// Log the error but no need to show it to the user.
// istanbul ignore next
// eslint-disable-next-line no-console
console.error('Unable to sync clipboard state across other open tabs:', error);
}
showToast(intl.formatMessage(messages.done));
}, [showToast, intl, queryClient]);
const handleBroadcastMessage = useCallback((event: MessageEvent) => {
// Note: if this useClipboard() hook is used many times on one page,
// this will result in many separate calls to setQueryData() whenever
// the clipboard contents change, but that is fine and shouldn't actually
// cause any issues. If it did, we could refactor this into a
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
// rather than having a separate channel per useClipboard hook.
queryClient.setQueryData(['clipboard'], event.data);
}, [queryClient]);
useEffect(() => {
// Handle messages from the broadcast channel
clipboardBroadcastChannel.onmessage = (event) => {
// Note: if this useClipboard() hook is used many times on one page,
// this will result in many separate calls to setQueryData() whenever
// the clipboard contents change, but that is fine and shouldn't actually
// cause any issues. If it did, we could refactor this into a
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
// rather than having a separate channel per useClipboard hook.
queryClient.setQueryData(['clipboard'], event.data);
};
clipboardBroadcastChannel?.addEventListener('message', handleBroadcastMessage);
// Cleanup function for the BroadcastChannel when the hook is unmounted
return () => {
clipboardBroadcastChannel.close();
clipboardBroadcastChannel?.removeEventListener('message', handleBroadcastMessage);
};
}, [clipboardBroadcastChannel]);
}, []);
const isPasteable = canEdit && clipboardData?.content?.status !== CLIPBOARD_STATUS.expired;
const showPasteUnit = isPasteable && clipboardData?.content?.blockType === 'vertical';

View File

@@ -38,12 +38,3 @@ export async function mockClipboardHtml(blockType?: string): Promise<api.Clipboa
}
mockClipboardHtml.applyMock = (blockType?: string) => jest.spyOn(api, 'getClipboard').mockImplementation(() => mockClipboardHtml(blockType));
mockClipboardHtml.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardHtml);
/** Mock the DOM `BroadcastChannel` API which the clipboard code uses */
export function mockBroadcastChannel() {
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
}

View File

@@ -3,7 +3,6 @@ import { getConfig } from '@edx/frontend-platform';
import { act, renderHook } from '@testing-library/react';
import { useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { mockBroadcastChannel } from '../../data/api.mock';
import { iframeMessageTypes, iframeStateKeys } from '../../../constants';
import { useIframeBehavior } from '../useIframeBehavior';
import { useLoadBearingHook } from '../useLoadBearingHook';
@@ -18,8 +17,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
mockBroadcastChannel();
describe('useIframeBehavior', () => {
const id = 'test-id';
const iframeUrl = 'http://example.com';

View File

@@ -21,7 +21,6 @@ import {
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import { studioHomeMock } from '../studio-home/__mocks__';
import { getStudioHomeApiUrl } from '../studio-home/data/api';
import { mockBroadcastChannel } from '../generic/data/api.mock';
import { LibraryLayout } from '.';
import { getLibraryCollectionsApiUrl, getLibraryContainersApiUrl } from './data/api';
@@ -34,7 +33,6 @@ mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetLibraryTeam.applyMock();
mockXBlockFields.applyMock();
mockBroadcastChannel();
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';

View File

@@ -47,13 +47,6 @@ jest.mock('../search-manager', () => ({
useSearchContext: () => mockUseSearchContext(),
}));
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const withLibraryId = (libraryId: string) => ({
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>

View File

@@ -15,7 +15,7 @@ import {
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
getXBlockFieldsApiUrl, getLibraryContainerChildrenApiUrl,
} from '../data/api';
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import AddContent from './AddContent';
import { ComponentEditorModal } from '../components/ComponentEditorModal';
@@ -23,7 +23,6 @@ import editorCmsApi from '../../editors/data/services/cms/api';
import { ToastActionData } from '../../generic/toast-context';
import * as textEditorHooks from '../../editors/containers/TextEditor/hooks';
mockBroadcastChannel();
// mockCreateLibraryBlock.applyMock();
// Mocks for ComponentEditorModal to work in tests.

View File

@@ -17,7 +17,7 @@ import {
mockCreateLibraryBlock,
mockXBlockFields,
} from '../data/api.mocks';
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { mockClipboardEmpty } from '../../generic/data/api.mock';
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
import { studioHomeMock } from '../../studio-home/__mocks__';
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
@@ -25,7 +25,6 @@ import LibraryLayout from '../LibraryLayout';
mockContentSearchConfig.applyMock();
mockClipboardEmpty.applyMock();
mockBroadcastChannel();
mockContentLibrary.applyMock();
mockCreateLibraryBlock.applyMock();
mockSearchResult(mockResult);

View File

@@ -17,7 +17,7 @@ import {
mockGetCollectionMetadata,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { mockClipboardEmpty } from '../../generic/data/api.mock';
import { LibraryLayout } from '..';
import { ContentTagsDrawer } from '../../content-tags-drawer';
import { getLibraryCollectionComponentApiUrl } from '../data/api';
@@ -31,7 +31,6 @@ mockContentSearchConfig.applyMock();
mockGetBlockTypes.applyMock();
mockContentLibrary.applyMock();
mockXBlockFields.applyMock();
mockBroadcastChannel();
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
const path = '/library/:libraryId/*';

View File

@@ -10,14 +10,12 @@ import {
mockGetUnpaginatedEntityLinks,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock';
import { mockBroadcastChannel } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
import ComponentInfo from './ComponentInfo';
import { getXBlockPublishApiUrl } from '../data/api';
mockContentSearchConfig.applyMock();
mockBroadcastChannel();
mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockGetUnpaginatedEntityLinks.applyMock();

View File

@@ -39,13 +39,6 @@ const contentHit: ContentHit = {
publishStatus: PublishStatus.Published,
};
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const libraryId = 'lib:org1:Demo_Course';
const render = () => baseRender(<ComponentCard hit={contentHit} />, {
extraWrapper: ({ children }) => (

View File

@@ -14,7 +14,7 @@ import {
mockLibraryBlockMetadata,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { mockClipboardEmpty } from '../../generic/data/api.mock';
import LibraryLayout from '../LibraryLayout';
const path = '/library/:libraryId/*';
@@ -28,7 +28,6 @@ mockGetBlockTypes.applyMock();
mockContentLibrary.applyMock();
mockXBlockFields.applyMock();
mockLibraryBlockMetadata.applyMock();
mockBroadcastChannel();
describe('<LibraryUnitPage />', () => {
beforeEach(() => {