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 (
);
diff --git a/src/generic/paste-button/components/index.js b/src/generic/clipboard/paste-button/components/index.js
similarity index 63%
rename from src/generic/paste-button/components/index.js
rename to src/generic/clipboard/paste-button/components/index.js
index 86980f4b9b..98b836f352 100644
--- a/src/generic/paste-button/components/index.js
+++ b/src/generic/clipboard/paste-button/components/index.js
@@ -1,3 +1,3 @@
export { default as WhatsInClipboard } from './WhatsInClipboard';
-export { default as PasteComponentButton } from './PasteComponentButton';
+export { default as PasteButtonComponent } from './PasteButtonComponent';
export { default as PopoverContent } from './PopoverContent';
diff --git a/src/generic/paste-button/constants.js b/src/generic/clipboard/paste-button/constants.js
similarity index 100%
rename from src/generic/paste-button/constants.js
rename to src/generic/clipboard/paste-button/constants.js
diff --git a/src/generic/paste-button/index.jsx b/src/generic/clipboard/paste-button/index.jsx
similarity index 82%
rename from src/generic/paste-button/index.jsx
rename to src/generic/clipboard/paste-button/index.jsx
index 926bacd8bd..e9eebefbee 100644
--- a/src/generic/paste-button/index.jsx
+++ b/src/generic/clipboard/paste-button/index.jsx
@@ -2,24 +2,15 @@ import { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
-import { PopoverContent, PasteComponentButton, WhatsInClipboard } from './components';
+import { PopoverContent, PasteButtonComponent, WhatsInClipboard } from './components';
import { clipboardPropsTypes, OVERLAY_TRIGGERS } from './constants';
const PasteButton = ({
- onClick, clipboardData, text, blockType,
+ onClick, clipboardData, text, className,
}) => {
const [showPopover, togglePopover] = useState(false);
const popoverElementRef = useRef(null);
- const showPasteButton = (
- clipboardData.content?.status === 'ready'
- && clipboardData.content?.blockType === blockType
- );
-
- if (!showPasteButton) {
- return null;
- }
-
const handlePopoverToggle = (isOpen) => togglePopover(isOpen);
const renderPopover = (props) => (
@@ -42,7 +33,7 @@ const PasteButton = ({
return (
<>
-
+