diff --git a/src/course-unit/__mocks__/clipboardUnit.js b/src/__mocks__/clipboardUnit.js similarity index 100% rename from src/course-unit/__mocks__/clipboardUnit.js rename to src/__mocks__/clipboardUnit.js diff --git a/src/course-unit/__mocks__/clipboardXBlock.js b/src/__mocks__/clipboardXBlock.js similarity index 100% rename from src/course-unit/__mocks__/clipboardXBlock.js rename to src/__mocks__/clipboardXBlock.js diff --git a/src/__mocks__/index.js b/src/__mocks__/index.js new file mode 100644 index 0000000000..b3b5984d3e --- /dev/null +++ b/src/__mocks__/index.js @@ -0,0 +1,2 @@ +export { default as clipboardUnit } from './clipboardUnit'; +export { default as clipboardXBlock } from './clipboardXBlock'; diff --git a/src/constants.js b/src/constants.js index 8a2c605781..ef9d6888ad 100644 --- a/src/constants.js +++ b/src/constants.js @@ -58,3 +58,14 @@ export const COURSE_BLOCK_NAMES = /** @type {const} */ ({ }); export const UPLOAD_FILE_MAX_SIZE = 20 * 1000 * 1000; // 20mb + +export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel'; + +export const CLIPBOARD_STATUS = { + loading: 'loading', + ready: 'ready', + expired: 'expired', + error: 'error', +}; + +export const NOT_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course']; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 592b29c8fc..cef569217e 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -3,7 +3,7 @@ import { } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; -import { initializeMockApp } from '@edx/frontend-platform'; +import { getConfig, initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { cloneDeep } from 'lodash'; @@ -35,6 +35,7 @@ import { courseSectionMock, courseSubsectionMock, } from './__mocks__'; +import { clipboardUnit } from '../__mocks__'; import { executeThunk } from '../utils'; import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants'; import CourseOutline from './CourseOutline'; @@ -44,7 +45,7 @@ import headerMessages from './header-navigations/messages'; import cardHeaderMessages from './card-header/messages'; import enableHighlightsModalMessages from './enable-highlights-modal/messages'; import statusBarMessages from './status-bar/messages'; -import pasteButtonMessages from './paste-button/messages'; +import pasteButtonMessages from '../generic/clipboard/paste-button/messages'; import subsectionMessages from './subsection-card/messages'; import pageAlertMessages from './page-alerts/messages'; import messages from './messages'; @@ -56,6 +57,13 @@ 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: () => ({ @@ -1803,7 +1811,7 @@ describe('', () => { }); it('check whether unit copy & paste option works correctly', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId, queryByTestId } = render(); // get first section -> first subsection -> first unit element const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); @@ -1814,27 +1822,11 @@ describe('', () => { const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); - const expectedClipboardContent = { - content: { - blockType: 'vertical', - blockTypeDisplay: 'Unit', - created: '2024-01-29T07:58:36.844249Z', - displayName: unit.displayName, - id: 15, - olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx', - purpose: 'clipboard', - status: 'ready', - userId: 3, - }, - sourceUsageKey: unit.id, - sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName, - sourceEditUrl: unit.studioUrl, - }; // mock api call axiosMock .onPost(getClipboardUrl(), { usage_key: unit.id, - }).reply(200, expectedClipboardContent); + }).reply(200, clipboardUnit); // check that initialUserClipboard state is empty const { initialUserClipboard } = store.getState().courseOutline; expect(initialUserClipboard).toBeUndefined(); @@ -1848,19 +1840,20 @@ describe('', () => { await act(async () => fireEvent.click(copyButton)); // check that initialUserClipboard state is updated - expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent); + expect(store.getState().courseOutline.initialUserClipboard).toEqual(clipboardUnit); [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); // find clipboard content label const clipboardLabel = await within(subsectionElement).findByText( - pasteButtonMessages.clipboardContentLabel.defaultMessage, + pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage, ); await act(async () => fireEvent.mouseOver(clipboardLabel)); - // find clipboard content popup link - expect( - subsectionElement.querySelector('#vertical-paste-button-overlay'), - ).toHaveAttribute('href', unit.studioUrl); + // find clipboard content popover link + const popoverContent = queryByTestId('popover-content'); + const apiBaseUrl = getConfig().STUDIO_BASE_URL; + expect(popoverContent.tagName).toBe('A'); + expect(popoverContent).toHaveAttribute('href', apiBaseUrl + unit.studioUrl); // check paste button functionality // mock api call diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index f5f4bd392f..af6a78ba0f 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -1,5 +1,5 @@ import { RequestStatus } from '../../data/constants'; -import { NOTIFICATION_MESSAGES } from '../../constants'; +import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants'; import { COURSE_BLOCK_NAMES } from '../constants'; import { hideProcessingNotification, @@ -581,7 +581,7 @@ export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, rest }; } -export function setClipboardContent(usageKey, broadcastClipboard) { +export function setClipboardContent(usageKey) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); @@ -589,9 +589,8 @@ export function setClipboardContent(usageKey, broadcastClipboard) { try { await copyBlockToClipboard(usageKey).then(async (result) => { const status = result?.content?.status; - if (status === 'ready') { + if (status === CLIPBOARD_STATUS.ready) { dispatch(updateClipboardContent(result)); - broadcastClipboard(result); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(hideProcessingNotification()); } else { diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index f29312db68..d7f686adea 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -6,12 +6,10 @@ import { getConfig } from '@edx/frontend-platform'; import { RequestStatus } from '../data/constants'; import { COURSE_BLOCK_NAMES } from './constants'; -import { useBroadcastChannel } from '../generic/broadcast-channel/hooks'; import { setCurrentItem, setCurrentSection, updateSavingStatus, - updateClipboardContent, } from './data/slice'; import { getLoadingStatus, @@ -91,12 +89,9 @@ const useCourseOutline = ({ courseId }) => { const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); - const clipboardBroadcastChannel = useBroadcastChannel('studio_clipboard_channel', (message) => { - dispatch(updateClipboardContent(message)); - }); const handleCopyToClipboardClick = (usageKey) => { - dispatch(setClipboardContent(usageKey, clipboardBroadcastChannel.postMessage)); + dispatch(setClipboardContent(usageKey)); }; const handlePasteClipboardClick = (parentLocator, sectionId) => { diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 3d5e518b0c..3d401b54ad 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -7,18 +7,16 @@ import { Button, useToggle } from '@openedx/paragon'; import { Add as IconAdd } from '@openedx/paragon/icons'; import classNames from 'classnames'; -import { getInitialUserClipboard } from 'CourseAuthoring/course-outline/data/selectors'; +import { getInitialUserClipboard } from '../data/selectors'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; -import { COURSE_BLOCK_NAMES } from '../constants'; import CardHeader from '../card-header/CardHeader'; import ConditionalSortableElement from '../../generic/drag-helper/ConditionalSortableElement'; +import { useCopyToClipboard, PasteButton } from '../../generic/clipboard'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; -// import PasteButton from '../paste-button/PasteButton'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; import messages from './messages'; -import PasteButton from '../../generic/paste-button'; const SubsectionCard = ({ section, @@ -47,6 +45,7 @@ const SubsectionCard = ({ const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'subsection'; const initialUserClipboard = useSelector(getInitialUserClipboard); + const { sharedClipboardData, showPasteUnit } = useCopyToClipboard(initialUserClipboard); const { id, @@ -62,7 +61,7 @@ const SubsectionCard = ({ // re-create actions object for customizations const actions = { ...subsectionActions }; - // add actions to control display of move up & down menu buton. + // add actions to control display of move up & down menu button. actions.allowMoveUp = canMoveItem(index, -1); actions.allowMoveDown = canMoveItem(index, 1); @@ -196,11 +195,11 @@ const SubsectionCard = ({ > {intl.formatMessage(messages.newUnitButton)} - {enableCopyPasteUnits && ( + {enableCopyPasteUnits && showPasteUnit && ( )} diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx index 17985f68d8..3a55f5e034 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { act, render, fireEvent, within, @@ -25,6 +24,13 @@ jest.mock('react-router-dom', () => ({ }), })); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const section = { id: '123', displayName: 'Section Name', diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 72399916e9..0a8bac83c1 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -12,7 +12,7 @@ import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; import getPageHeadTitle from '../generic/utils'; import AlertMessage from '../generic/alert-message'; -import PasteButton from '../generic/paste-button'; +import { PasteButton } from '../generic/clipboard'; import ProcessingNotification from '../generic/processing-notification'; import { SavingErrorAlert } from '../generic/saving-error-alert'; import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 48a6229b11..db47905e20 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -27,8 +27,6 @@ import { } from './data/thunk'; import initializeStore from '../store'; import { - clipboardUnit, - clipboardXBlock, courseCreateXblockMock, courseSectionVerticalMock, courseUnitIndexMock, @@ -36,9 +34,13 @@ import { courseVerticalChildrenMock, clipboardMockResponse, } from './__mocks__'; +import { + clipboardUnit, + clipboardXBlock, +} from '../__mocks__'; import { executeThunk } from '../utils'; import deleteModalMessages from '../generic/delete-modal/messages'; -import pasteComponentMessages from './clipboard/paste-component/messages'; +import pasteButtonMessages from '../generic/clipboard/paste-button/messages'; import pasteNotificationsMessages from './clipboard/paste-notification/messages'; import headerNavigationsMessages from './header-navigations/messages'; import headerTitleMessages from './header-title/messages'; @@ -928,7 +930,7 @@ describe('', () => { await waitFor(() => { expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull(); - expect(queryByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeNull(); + expect(queryByRole('button', { name: messages.pasteComponentButtonText.defaultMessage })).toBeNull(); }); axiosMock @@ -961,10 +963,10 @@ describe('', () => { }); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); const whatsInClipboardText = getByText( - pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage, + pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage, ); userEvent.hover(whatsInClipboardText); @@ -1010,7 +1012,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(copyToClipboard(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })); + userEvent.click(getByRole('button', { name: messages.pasteComponentButtonText.defaultMessage })); await waitFor(() => { expect(getAllByTestId('course-xblock')).toHaveLength(2); @@ -1053,7 +1055,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(copyToClipboard(blockId), store.dispatch); - expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); }); it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { @@ -1319,10 +1321,10 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); expect(queryByRole('button', { - name: pasteComponentMessages.pasteComponentButtonText.defaultMessage, + name: messages.pasteComponentButtonText.defaultMessage, })).not.toBeInTheDocument(); expect(queryByText( - pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage, + pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage, )).not.toBeInTheDocument(); }); }); diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js index 88072ae83e..8810e61e07 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -3,6 +3,4 @@ export { default as courseSectionVerticalMock } from './courseSectionVertical'; export { default as courseUnitMock } from './courseUnit'; export { default as courseCreateXblockMock } from './courseCreateXblock'; export { default as courseVerticalChildrenMock } from './courseVerticalChildren'; -export { default as clipboardUnit } from './clipboardUnit'; -export { default as clipboardXBlock } from './clipboardXBlock'; export { default as clipboardMockResponse } from './clipboardResponse'; diff --git a/src/course-unit/clipboard/hooks/useClipboard.test.jsx b/src/course-unit/clipboard/hooks/useClipboard.test.jsx deleted file mode 100644 index 049cd52477..0000000000 --- a/src/course-unit/clipboard/hooks/useClipboard.test.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { Provider } from 'react-redux'; -import { initializeMockApp } from '@edx/frontend-platform'; -import MockAdapter from 'axios-mock-adapter'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; - -import initializeStore from '../../../store'; -import { executeThunk } from '../../../utils'; -import { copyToClipboard } from '../../data/thunk'; -import { getClipboardUrl } from '../../data/api'; -import { clipboardUnit, clipboardXBlock } from '../../__mocks__'; -import useClipboard from './useClipboard'; - -let axiosMock; -let store; -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(), -}; -global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); - -const wrapper = ({ children }) => ( - - - {children} - - -); - -describe('useCopyToClipboard', () => { - beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - }); - - it('initializes correctly', () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); - - expect(result.current.showPasteUnit).toBe(false); - expect(result.current.showPasteXBlock).toBe(false); - }); - - describe('clipboard data update effect', () => { - it('returns falsy flags if canEdit = false', async () => { - const { result } = renderHook(() => useClipboard(false), { wrapper }); - - axiosMock - .onPost(getClipboardUrl()) - .reply(200, clipboardUnit); - axiosMock - .onGet(getClipboardUrl()) - .reply(200, clipboardUnit); - - await act(async () => { - await executeThunk(copyToClipboard(unitId), store.dispatch); - }); - expect(result.current.showPasteUnit).toBe(false); - expect(result.current.showPasteXBlock).toBe(false); - }); - - it('returns flag to display the Paste Unit button', async () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); - - axiosMock - .onPost(getClipboardUrl()) - .reply(200, clipboardUnit); - axiosMock - .onGet(getClipboardUrl()) - .reply(200, clipboardUnit); - - await act(async () => { - await executeThunk(copyToClipboard(unitId), store.dispatch); - }); - expect(result.current.showPasteUnit).toBe(true); - expect(result.current.showPasteXBlock).toBe(false); - }); - - it('returns flag to display the Paste XBlock button', async () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); - - axiosMock - .onPost(getClipboardUrl()) - .reply(200, clipboardXBlock); - axiosMock - .onGet(getClipboardUrl()) - .reply(200, clipboardXBlock); - - await act(async () => { - await executeThunk(copyToClipboard(xblockId), store.dispatch); - }); - expect(result.current.showPasteUnit).toBe(false); - expect(result.current.showPasteXBlock).toBe(true); - }); - }); - - describe('broadcast channel message handling', () => { - it('updates states correctly on receiving a broadcast message', async () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); - clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit }); - - expect(result.current.showPasteUnit).toBe(true); - expect(result.current.showPasteXBlock).toBe(false); - - clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock }); - expect(result.current.showPasteUnit).toBe(false); - expect(result.current.showPasteXBlock).toBe(true); - }); - }); -}); diff --git a/src/course-unit/clipboard/index.js b/src/course-unit/clipboard/index.js index 6675be1e86..22e541cc9e 100644 --- a/src/course-unit/clipboard/index.js +++ b/src/course-unit/clipboard/index.js @@ -1,2 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export export { default as PasteNotificationAlert } from './paste-notification'; -export { default as useCopyToClipboard } from './hooks/useClipboard'; diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 7d5977a3c2..7ed9e323d7 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -24,10 +24,11 @@ import { getSequenceStatus, getStaticFileNotices, getCanEdit, + getClipboardData, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; -import { useCopyToClipboard } from './clipboard'; +import { useCopyToClipboard } from '../generic/clipboard'; import { PUBLISH_TYPES } from './constants'; // eslint-disable-next-line import/prefer-default-export @@ -46,7 +47,8 @@ export const useCourseUnit = ({ courseId, blockId }) => { const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen); const canEdit = useSelector(getCanEdit); const { currentlyVisibleToStudents } = courseUnit; - const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); + const clipboardData = useSelector(getClipboardData); + const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(clipboardData, canEdit); const { canPasteComponent } = courseVerticalChildren; const unitTitle = courseUnit.metadata?.displayName || ''; diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js index 024deac04a..8525886ca5 100644 --- a/src/course-unit/messages.js +++ b/src/course-unit/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.xblock.alert.unpublished-version.description', defaultMessage: 'Note: The last published version of this unit is live. By publishing changes you will change the student experience.', }, + pasteComponentButtonText: { + id: 'course-authoring.course-unit.paste-component.btn.text', + defaultMessage: 'Paste component', + }, }); export default messages; 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 a91aba196b..290a1f2c53 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -8,9 +8,10 @@ import userEvent from '@testing-library/user-event'; import initializeStore from '../../../../store'; import { executeThunk } from '../../../../utils'; +import { clipboardUnit } from '../../../../__mocks__'; import { getClipboardUrl, getCourseUnitApiUrl } from '../../../data/api'; import { copyToClipboard, fetchCourseUnitQuery } from '../../../data/thunk'; -import { clipboardUnit, courseUnitIndexMock } from '../../../__mocks__'; +import { courseUnitIndexMock } from '../../../__mocks__'; import messages from '../../messages'; import ActionButtons from './ActionButtons'; diff --git a/src/generic/broadcast-channel/hooks.js b/src/generic/broadcast-channel/hooks.js deleted file mode 100644 index 9ed71c1657..0000000000 --- a/src/generic/broadcast-channel/hooks.js +++ /dev/null @@ -1,45 +0,0 @@ -import { - useCallback, useEffect, useMemo, useRef, -} from 'react'; - -const channelInstances = {}; - -export const getSingletonChannel = (name) => { - if (!channelInstances[name]) { - channelInstances[name] = new BroadcastChannel(name); - } - return channelInstances[name]; -}; - -export const useBroadcastChannel = (channelName, onMessageReceived) => { - const channel = useMemo(() => getSingletonChannel(channelName), [channelName]); - const isSubscribed = useRef(false); - - useEffect(() => { - if (!isSubscribed.current || process.env.NODE_ENV !== 'development') { - // BroadcastChannel api from npm has minor difference from native BroadcastChannel - // Native BroadcastChannel passes event to onmessage callback and to - // access data we need to use `event.data`, but npm BroadcastChannel - // directly passes data as seen below - channel.onmessage = (event) => onMessageReceived(event.data); - } - return () => { - if (isSubscribed.current || process.env.NODE_ENV !== 'development') { - channel.close(); - isSubscribed.current = true; - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const postMessage = useCallback( - (message) => { - channel?.postMessage(message); - }, - [channel], - ); - - return { - postMessage, - }; -}; diff --git a/src/course-unit/clipboard/hooks/useClipboard.jsx b/src/generic/clipboard/hooks/useCopyToClipboard.js similarity index 70% rename from src/course-unit/clipboard/hooks/useClipboard.jsx rename to src/generic/clipboard/hooks/useCopyToClipboard.js index 0d0c6a82de..83d7f50709 100644 --- a/src/course-unit/clipboard/hooks/useClipboard.jsx +++ b/src/generic/clipboard/hooks/useCopyToClipboard.js @@ -1,14 +1,21 @@ import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { getClipboardData } from '../../data/selectors'; -import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; - -const useCopyToClipboard = (canEdit) => { +import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants'; + +/** + * Custom React hook for managing clipboard functionality. + * + * @param {Object} clipboardData - The clipboard data object. + * @param {boolean} canEdit - Flag indicating whether the clipboard is editable. + * @returns {Object} - An object containing state variables and functions related to clipboard functionality. + * @property {boolean} showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible. + * @property {boolean} showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible. + * @property {Object} sharedClipboardData - The shared clipboard data object. + */ +const useCopyToClipboard = (clipboardData, canEdit = true) => { const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); const [showPasteUnit, setShowPasteUnit] = useState(false); const [showPasteXBlock, setShowPasteXBlock] = useState(false); - const clipboardData = useSelector(getClipboardData); const [sharedClipboardData, setSharedClipboardData] = useState({}); // Function to refresh the paste button's visibility diff --git a/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx new file mode 100644 index 0000000000..e3752c87ba --- /dev/null +++ b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx @@ -0,0 +1,123 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { act } from 'react-dom/test-utils'; + +import initializeStore from '../../../store'; +import useCopyToClipboard from './useCopyToClipboard'; + +const clipboardUnit = { + content: { + id: 67, + userId: 3, + created: '2024-01-16T13:09:11.540615Z', + purpose: 'clipboard', + status: 'ready', + blockType: 'vertical', + blockTypeDisplay: 'Unit', + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx', + displayName: 'Introduction: Video and Sequences', + }, + sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', + sourceContextTitle: 'Demonstration Course', + sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', +}; + +const clipboardXBlock = { + content: { + id: 69, + userId: 3, + created: '2024-01-16T13:33:21.314439Z', + purpose: 'clipboard', + status: 'ready', + blockType: 'html', + blockTypeDisplay: 'Text', + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx', + displayName: 'Blank HTML Page', + }, + sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1', + sourceContextTitle: 'Demonstration Course', + sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1', +}; + +let store; + +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + +const wrapper = ({ children }) => ( + + + {children} + + +); + +describe('useCopyToClipboard', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + }); + + it('initializes correctly', () => { + const { result } = renderHook(() => useCopyToClipboard(clipboardUnit, true), { wrapper }); + + expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteXBlock).toBe(false); + }); + + it('should update state and broadcast channel on clipboardData change', () => { + const { result, rerender } = renderHook(({ clipboardData }) => useCopyToClipboard(clipboardData, true), { + initialProps: { clipboardData: clipboardUnit }, + }); + + expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteXBlock).toBe(false); + expect(result.current.sharedClipboardData).toEqual(clipboardUnit); + + act(() => { + rerender({ clipboardData: clipboardXBlock }); + }); + + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(true); + expect(result.current.sharedClipboardData).toEqual(clipboardXBlock); + }); + + it('should update state and broadcast channel when canEdit is false', () => { + const { result } = renderHook(({ clipboardData }) => useCopyToClipboard(clipboardData, false), { + initialProps: { clipboardData: clipboardUnit }, + }); + + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(false); + expect(result.current.sharedClipboardData).toEqual({}); + }); + + it('updates states correctly on receiving a broadcast message', async () => { + const { result } = renderHook(({ clipboardData }) => useCopyToClipboard(clipboardData, true), { + initialProps: { clipboardData: clipboardUnit }, + }); + clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit }); + + expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteXBlock).toBe(false); + + clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock }); + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(true); + }); +}); diff --git a/src/generic/clipboard/index.js b/src/generic/clipboard/index.js new file mode 100644 index 0000000000..f5b9a7e5a8 --- /dev/null +++ b/src/generic/clipboard/index.js @@ -0,0 +1,2 @@ +export { default as useCopyToClipboard } from './hooks/useCopyToClipboard'; +export { default as PasteButton } from './paste-button'; diff --git a/src/generic/paste-button/PasteButton.scss b/src/generic/clipboard/paste-button/PasteButton.scss similarity index 100% rename from src/generic/paste-button/PasteButton.scss rename to src/generic/clipboard/paste-button/PasteButton.scss diff --git a/src/generic/paste-button/components/PasteComponentButton.jsx b/src/generic/clipboard/paste-button/components/PasteButtonComponent.jsx similarity index 70% rename from src/generic/paste-button/components/PasteComponentButton.jsx rename to src/generic/clipboard/paste-button/components/PasteButtonComponent.jsx index 29d7128f9c..0d1e68f93b 100644 --- a/src/generic/paste-button/components/PasteComponentButton.jsx +++ b/src/generic/clipboard/paste-button/components/PasteButtonComponent.jsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom'; import { Button } from '@openedx/paragon'; import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons'; -const PasteComponentButton = ({ onClick, text }) => { +const PasteButtonComponent = ({ onClick, text, className }) => { const { blockId } = useParams(); const handlePasteXBlockComponent = () => { @@ -12,6 +12,7 @@ const PasteComponentButton = ({ onClick, text }) => { return (