diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index bf38a08f5..05ff96205 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -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(), diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx index 674afc8da..4979506ab 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.jsx @@ -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', }; diff --git a/src/course-outline/unit-card/UnitCard.test.jsx b/src/course-outline/unit-card/UnitCard.test.jsx index db0ed71aa..91e7207d9 100644 --- a/src/course-outline/unit-card/UnitCard.test.jsx +++ b/src/course-outline/unit-card/UnitCard.test.jsx @@ -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( diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index d92d5d4b8..2f128c001 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -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 diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx index 9304350fc..16bd0ef9a 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -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( diff --git a/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx index 124226b79..17dfe14b4 100644 --- a/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/tests/hooks.test.tsx @@ -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; diff --git a/src/generic/clipboard/hooks/useClipboard.test.tsx b/src/generic/clipboard/hooks/useClipboard.test.tsx index 608942b95..0b73ef8b4 100644 --- a/src/generic/clipboard/hooks/useClipboard.test.tsx +++ b/src/generic/clipboard/hooks/useClipboard.test.tsx @@ -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); diff --git a/src/generic/clipboard/hooks/useClipboard.ts b/src/generic/clipboard/hooks/useClipboard.ts index 19ac231ec..63cff384e 100644 --- a/src/generic/clipboard/hooks/useClipboard.ts +++ b/src/generic/clipboard/hooks/useClipboard.ts @@ -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 + // 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 - // 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'; diff --git a/src/generic/data/api.mock.ts b/src/generic/data/api.mock.ts index b4a1e43dc..f162b7ae1 100644 --- a/src/generic/data/api.mock.ts +++ b/src/generic/data/api.mock.ts @@ -38,12 +38,3 @@ export async function mockClipboardHtml(blockType?: string): Promise 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); -} diff --git a/src/generic/hooks/tests/hooks.test.tsx b/src/generic/hooks/tests/hooks.test.tsx index da284b900..bf15d83ba 100644 --- a/src/generic/hooks/tests/hooks.test.tsx +++ b/src/generic/hooks/tests/hooks.test.tsx @@ -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'; diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 89c481226..06ca4fbe2 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -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'; diff --git a/src/library-authoring/LibraryContent.test.tsx b/src/library-authoring/LibraryContent.test.tsx index f5dcb0dd2..810c77f48 100644 --- a/src/library-authoring/LibraryContent.test.tsx +++ b/src/library-authoring/LibraryContent.test.tsx @@ -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 }) => ( diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index aebc7241e..5d75b8976 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -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. diff --git a/src/library-authoring/add-content/AddContentWorkflow.test.tsx b/src/library-authoring/add-content/AddContentWorkflow.test.tsx index f12dc0d91..8244fc716 100644 --- a/src/library-authoring/add-content/AddContentWorkflow.test.tsx +++ b/src/library-authoring/add-content/AddContentWorkflow.test.tsx @@ -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); diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index 2397cbb75..2258e1846 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -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/*'; diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx index c5a107b64..2206be3e5 100644 --- a/src/library-authoring/component-info/ComponentInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentInfo.test.tsx @@ -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(); diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx index adea38568..087b0ad20 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -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(, { extraWrapper: ({ children }) => ( diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index b2726fab0..9e039a9da 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -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('', () => { beforeEach(() => {