From d4fe410d124e6879df56a651e06eb44702343549 Mon Sep 17 00:00:00 2001
From: monteri <36768631+monteri@users.noreply.github.com>
Date: Tue, 30 Jan 2024 13:01:26 +0200
Subject: [PATCH 1/5] feat: Copy/paste functionality
* feat: [AXIMST-344] Copy/paste functionality base
* feat: [AXIMST-344] Copy/paste functionality visible part
* feat: tests
* fix: PR comment review
* refactor: refactoring after review
* refactor: refactoring after rebase
---------
Co-authored-by: monteri
Co-authored-by: PKulkoRaccoonGang
feat: [AXIMST-375] Course unit - Added functionality for copying and pasting xblocks and units (#147)
* feat: [AXIMST-350] added functionality for copying and pasting xblocks and units
* refactor: refactoring after review
* refactor: refactoring after second review
fix: [AXIMST-480] fixed paste notification behavior after switching a unit (#160)
fix: [AXIMST-478] fixed copy-paste tooltip (#161)
feat: [AXIMST-338] Course unit - Added canEdit and canPasteComponent variables (#170)
* feat: [AXIMST-338] added canEdit and canPasteComponent variables
* refactor: added condition for Can copy Unit btn
feat: [AXIMST-525] separated the copy unit button (#190)
refactor: [AXIMST-507] Course unit - Changed Paste unit UI (#186)
* refactor: [AXIMST-507] changed Paste unit UI
* refactor: code refactoring
fix: fixed react-intl error (#197)
fix: [AXIMST-516] fixed paste alerts view (#189)
refactor: code refactoring
refactor: code refactoring
---
package.json | 3 +
src/course-unit/CourseUnit.jsx | 19 +
src/course-unit/CourseUnit.scss | 1 +
src/course-unit/CourseUnit.test.jsx | 440 +++++++++++++++++-
.../__mocks__/clipboardResponse.js | 9 +
src/course-unit/__mocks__/clipboardUnit.js | 16 +
src/course-unit/__mocks__/clipboardXBlock.js | 16 +
.../__mocks__/courseVerticalChildren.js | 1 +
src/course-unit/__mocks__/index.js | 3 +
.../clipboard/hooks/useClipboard.jsx | 52 +++
.../clipboard/hooks/useClipboard.test.jsx | 121 +++++
src/course-unit/clipboard/index.js | 3 +
.../paste-component/PasteComponent.scss | 46 ++
.../components/PasteComponentButton.jsx | 33 ++
.../components/PopoverContent.jsx | 47 ++
.../components/WhatsInClipboard.jsx | 58 +++
.../paste-component/components/index.js | 3 +
.../clipboard/paste-component/constants.js | 12 +
.../clipboard/paste-component/index.jsx | 61 +++
.../clipboard/paste-component/messages.js | 18 +
.../components/ActionButton.jsx | 21 +
.../components/AlertContent.jsx | 22 +
.../components/FileList.jsx | 21 +
.../paste-notification/components/index.js | 3 +
.../clipboard/paste-notification/constants.js | 7 +
.../clipboard/paste-notification/index.jsx | 107 +++++
.../clipboard/paste-notification/messages.js | 38 ++
.../clipboard/paste-notification/utils.js | 12 +
src/course-unit/constants.js | 16 +
.../course-sequence/CourseSequence.scss | 4 +-
src/course-unit/course-sequence/Sequence.jsx | 3 +
src/course-unit/course-sequence/messages.js | 4 +
.../SequenceNavigation.jsx | 3 +
.../SequenceNavigationDropdown.jsx | 26 +-
.../SequenceNavigationTabs.jsx | 31 +-
.../course-xblock/CourseXBlock.jsx | 17 +-
src/course-unit/course-xblock/messages.js | 4 +
src/course-unit/data/api.js | 35 +-
src/course-unit/data/selectors.js | 4 +
src/course-unit/data/slice.js | 11 +
src/course-unit/data/thunk.js | 55 ++-
src/course-unit/hooks.jsx | 13 +
src/course-unit/sidebar/Sidebar.scss | 8 +
.../sidebar-footer/ActionButtons.jsx | 31 +-
.../sidebar-footer/ActionButtons.test.jsx | 77 +++
src/generic/divider/Divider.jsx | 16 +
src/generic/divider/Divider.scss | 5 +
src/generic/divider/index.jsx | 2 +
src/generic/styles.scss | 1 +
49 files changed, 1524 insertions(+), 35 deletions(-)
create mode 100644 src/course-unit/__mocks__/clipboardResponse.js
create mode 100644 src/course-unit/__mocks__/clipboardUnit.js
create mode 100644 src/course-unit/__mocks__/clipboardXBlock.js
create mode 100644 src/course-unit/clipboard/hooks/useClipboard.jsx
create mode 100644 src/course-unit/clipboard/hooks/useClipboard.test.jsx
create mode 100644 src/course-unit/clipboard/index.js
create mode 100644 src/course-unit/clipboard/paste-component/PasteComponent.scss
create mode 100644 src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx
create mode 100644 src/course-unit/clipboard/paste-component/components/PopoverContent.jsx
create mode 100644 src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx
create mode 100644 src/course-unit/clipboard/paste-component/components/index.js
create mode 100644 src/course-unit/clipboard/paste-component/constants.js
create mode 100644 src/course-unit/clipboard/paste-component/index.jsx
create mode 100644 src/course-unit/clipboard/paste-component/messages.js
create mode 100644 src/course-unit/clipboard/paste-notification/components/ActionButton.jsx
create mode 100644 src/course-unit/clipboard/paste-notification/components/AlertContent.jsx
create mode 100644 src/course-unit/clipboard/paste-notification/components/FileList.jsx
create mode 100644 src/course-unit/clipboard/paste-notification/components/index.js
create mode 100644 src/course-unit/clipboard/paste-notification/constants.js
create mode 100644 src/course-unit/clipboard/paste-notification/index.jsx
create mode 100644 src/course-unit/clipboard/paste-notification/messages.js
create mode 100644 src/course-unit/clipboard/paste-notification/utils.js
create mode 100644 src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx
create mode 100644 src/generic/divider/Divider.jsx
create mode 100644 src/generic/divider/Divider.scss
create mode 100644 src/generic/divider/index.jsx
diff --git a/package.json b/package.json
index 32987c4d0a..3b923bc997 100644
--- a/package.json
+++ b/package.json
@@ -121,5 +121,8 @@
},
"peerDependencies": {
"decode-uri-component": ">=0.2.2"
+ },
+ "overrides": {
+ "react-intl": "^6.4.0"
}
}
diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx
index 9df9a9be29..f6a2c5b0e7 100644
--- a/src/course-unit/CourseUnit.jsx
+++ b/src/course-unit/CourseUnit.jsx
@@ -27,6 +27,7 @@ import messages from './messages';
import PublishControls from './sidebar/PublishControls';
import LocationInfo from './sidebar/LocationInfo';
import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
+import { PasteNotificationAlert, PasteComponent } from './clipboard';
const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
@@ -40,9 +41,13 @@ const CourseUnit = ({ courseId }) => {
savingStatus,
isTitleEditFormOpen,
isErrorAlert,
+ staticFileNotices,
currentlyVisibleToStudents,
isInternetConnectionAlertFailed,
unitXBlockActions,
+ sharedClipboardData,
+ showPasteXBlock,
+ showPasteUnit,
handleTitleEditSubmit,
headerNavigationsActions,
handleTitleEdit,
@@ -50,6 +55,7 @@ const CourseUnit = ({ courseId }) => {
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
+ canPasteComponent,
} = useCourseUnit({ courseId, blockId });
document.title = getPageHeadTitle('', unitTitle);
@@ -103,6 +109,7 @@ const CourseUnit = ({ courseId }) => {
sequenceId={sequenceId}
unitId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
+ showPasteUnit={showPasteUnit}
/>
{
icon={WarningIcon}
/>
)}
+ {staticFileNotices && (
+
+ )}
{courseVerticalChildren.children.map(({
name, blockId: id, blockType: type, shouldScroll, userPartitionInfo, validationMessages,
@@ -142,6 +155,12 @@ const CourseUnit = ({ courseId }) => {
blockId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
/>
+ {showPasteXBlock && canPasteComponent && (
+
+ )}
diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss
index 6e380bf9d2..b46ba52d6a 100644
--- a/src/course-unit/CourseUnit.scss
+++ b/src/course-unit/CourseUnit.scss
@@ -4,3 +4,4 @@
@import "./course-xblock/CourseXBlock";
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";
+@import "./clipboard/paste-component/PasteComponent";
diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx
index 24d55a9e1e..65dfd00054 100644
--- a/src/course-unit/CourseUnit.test.jsx
+++ b/src/course-unit/CourseUnit.test.jsx
@@ -5,7 +5,7 @@ import {
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
-import { initializeMockApp } from '@edx/frontend-platform';
+import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { cloneDeep, set } from 'lodash';
@@ -17,6 +17,8 @@ import {
postXBlockBaseApiUrl,
} from './data/api';
import {
+ copyToClipboard,
+ createNewCourseXBlock,
deleteUnitItemQuery,
editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
@@ -25,13 +27,19 @@ import {
} from './data/thunk';
import initializeStore from '../store';
import {
+ clipboardUnit,
+ clipboardXBlock,
courseCreateXblockMock,
courseSectionVerticalMock,
courseUnitIndexMock,
courseUnitMock,
courseVerticalChildrenMock,
+ clipboardMockResponse,
} from './__mocks__';
import { executeThunk } from '../utils';
+import deleteModalMessages from '../generic/delete-modal/messages';
+import pasteComponentMessages from './clipboard/paste-component/messages';
+import pasteNotificationsMessages from './clipboard/paste-notification/messages';
import headerNavigationsMessages from './header-navigations/messages';
import headerTitleMessages from './header-title/messages';
import courseSequenceMessages from './course-sequence/messages';
@@ -39,7 +47,6 @@ import sidebarMessages from './sidebar/messages';
import { extractCourseUnitId } from './sidebar/utils';
import CourseUnit from './CourseUnit';
-import deleteModalMessages from '../generic/delete-modal/messages';
import configureModalMessages from '../generic/configure-modal/messages';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
@@ -55,6 +62,11 @@ const unitDisplayName = courseUnitIndexMock.metadata.display_name;
const mockedUsedNavigate = jest.fn();
const userName = 'openedx';
+const postXBlockBody = {
+ parent_locator: blockId,
+ staged_content: 'clipboard',
+};
+
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ blockId }),
@@ -86,6 +98,13 @@ jest.mock('@tanstack/react-query', () => ({
})),
}));
+const clipboardBroadcastChannelMock = {
+ postMessage: jest.fn(),
+ close: jest.fn(),
+};
+
+global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
+
const RootWrapper = () => (
@@ -104,7 +123,7 @@ describe('', () => {
roles: [],
},
});
-
+ global.localStorage.clear();
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
@@ -311,7 +330,7 @@ describe('', () => {
await waitFor(async () => {
units = getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
- expect(units.length).toEqual(courseUnits.length);
+ expect(units).toHaveLength(courseUnits.length);
});
axiosMock
@@ -1013,4 +1032,417 @@ describe('', () => {
.getByText(sidebarMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
});
});
+
+ describe('Copy paste functionality', () => {
+ it('should display "Copy Unit" action button after enabling copy-paste units', async () => {
+ const { queryByText, queryByRole } = render();
+
+ await waitFor(() => {
+ expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull();
+ expect(queryByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeNull();
+ });
+
+ axiosMock
+ .onGet(getCourseUnitApiUrl(courseId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ enable_copy_paste_units: true,
+ });
+
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
+ expect(queryByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })).toBeInTheDocument();
+ });
+
+ it('should display clipboard information in popover when hovering over What\'s in clipboard text', async () => {
+ const {
+ queryByTestId, getByRole, getAllByLabelText, getByText,
+ } = render();
+
+ await waitFor(() => {
+ const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
+ userEvent.click(xblockActionBtn);
+ userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
+ });
+
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...courseSectionVerticalMock,
+ user_clipboard: clipboardXBlock,
+ });
+
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument();
+
+ const whatsInClipboardText = getByText(
+ pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage,
+ );
+
+ userEvent.hover(whatsInClipboardText);
+
+ const popoverContent = queryByTestId('popover-content');
+ expect(popoverContent.tagName).toBe('A');
+ expect(popoverContent).toHaveAttribute('href', clipboardXBlock.sourceEditUrl);
+ expect(within(popoverContent).getByText(clipboardXBlock.content.displayName)).toBeInTheDocument();
+ expect(within(popoverContent).getByText(clipboardXBlock.sourceContextTitle)).toBeInTheDocument();
+ expect(within(popoverContent).getByText(clipboardXBlock.content.blockTypeDisplay)).toBeInTheDocument();
+
+ fireEvent.blur(whatsInClipboardText);
+ await waitFor(() => expect(queryByTestId('popover-content')).toBeNull());
+
+ fireEvent.focus(whatsInClipboardText);
+ await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument());
+
+ fireEvent.mouseLeave(whatsInClipboardText);
+ await waitFor(() => expect(queryByTestId('popover-content')).toBeNull());
+
+ fireEvent.mouseEnter(whatsInClipboardText);
+ await waitFor(() => expect(queryByTestId('popover-content')).toBeInTheDocument());
+ });
+
+ it('should increase the number of course XBlocks after copying and pasting a block', async () => {
+ const {
+ getAllByTestId, getByRole, getAllByLabelText,
+ } = render();
+
+ await waitFor(() => {
+ const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
+ userEvent.click(xblockActionBtn);
+ userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
+ });
+
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...courseSectionVerticalMock,
+ user_clipboard: clipboardXBlock,
+ });
+
+ axiosMock
+ .onGet(getCourseUnitApiUrl(courseId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ enable_copy_paste_units: true,
+ });
+
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
+ userEvent.click(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage }));
+
+ expect(getAllByTestId('course-xblock')).toHaveLength(2);
+
+ axiosMock
+ .onGet(getCourseVerticalChildrenApiUrl(blockId))
+ .reply(200, {
+ ...courseVerticalChildrenMock,
+ children: [
+ ...courseVerticalChildrenMock.children,
+ {
+ name: 'Copy XBlock',
+ block_id: '1234567890',
+ block_type: 'drag-and-drop-v2',
+ user_partition_info: {
+ selectable_partitions: [],
+ selected_partition_index: -1,
+ selected_groups_label: '',
+ },
+ },
+ ],
+ });
+
+ await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
+ expect(getAllByTestId('course-xblock')).toHaveLength(3);
+ });
+
+ it('should display the "Paste component" button after copying a xblock to clipboard', async () => {
+ const { getByRole, getAllByLabelText } = render();
+
+ await waitFor(() => {
+ const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
+ userEvent.click(xblockActionBtn);
+ userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage }));
+ });
+
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...courseSectionVerticalMock,
+ user_clipboard: clipboardXBlock,
+ });
+
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(copyToClipboard(blockId), store.dispatch);
+ expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument();
+ });
+
+ it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => {
+ const {
+ getAllByTestId, getByRole,
+ } = render();
+
+ axiosMock
+ .onGet(getCourseUnitApiUrl(courseId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ enable_copy_paste_units: true,
+ });
+
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
+
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...courseSectionVerticalMock,
+ user_clipboard: clipboardUnit,
+ });
+
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(copyToClipboard(blockId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
+
+ let units = null;
+ const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
+ const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
+ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
+ ...updatedAncestorsChild.child_info.children,
+ courseUnitMock,
+ ]);
+
+ units = getAllByTestId('course-unit-btn');
+ const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
+ expect(units).toHaveLength(courseUnits.length);
+
+ axiosMock
+ .onPost(postXBlockBaseApiUrl(), postXBlockBody)
+ .reply(200, { dummy: 'value' });
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...updatedCourseSectionVerticalData,
+ });
+
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+
+ units = getAllByTestId('course-unit-btn');
+ const updatedCourseUnits = updatedCourseSectionVerticalData
+ .xblock_info.ancestor_info.ancestors[0].child_info.children;
+
+ expect(units.length).toEqual(updatedCourseUnits.length);
+ expect(mockedUsedNavigate).toHaveBeenCalled();
+ expect(mockedUsedNavigate)
+ .toHaveBeenCalledWith(`/course/${courseId}/container/${blockId}/${updatedAncestorsChild.id}`, { replace: true });
+ });
+
+ it('displays a notification about new files after pasting a component', async () => {
+ const {
+ queryByTestId, getByTestId, getByRole,
+ } = render();
+
+ axiosMock
+ .onGet(getCourseUnitApiUrl(courseId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ enable_copy_paste_units: true,
+ });
+
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
+
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...courseSectionVerticalMock,
+ user_clipboard: clipboardUnit,
+ });
+
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(copyToClipboard(blockId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
+
+ const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
+ const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
+ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
+ ...updatedAncestorsChild.child_info.children,
+ courseUnitMock,
+ ]);
+
+ axiosMock
+ .onPost(postXBlockBaseApiUrl(postXBlockBody))
+ .reply(200, clipboardMockResponse);
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...updatedCourseSectionVerticalData,
+ });
+
+ global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
+ const newFilesAlert = getByTestId('has-new-files-alert');
+
+ expect(within(newFilesAlert)
+ .getByText(pasteNotificationsMessages.hasNewFilesTitle.defaultMessage)).toBeInTheDocument();
+ expect(within(newFilesAlert)
+ .getByText(pasteNotificationsMessages.hasNewFilesDescription.defaultMessage)).toBeInTheDocument();
+ expect(within(newFilesAlert)
+ .getByText(pasteNotificationsMessages.hasNewFilesButtonText.defaultMessage)).toBeInTheDocument();
+ clipboardMockResponse.staticFileNotices.newFiles.forEach((fileName) => {
+ expect(within(newFilesAlert).getByText(fileName)).toBeInTheDocument();
+ });
+
+ userEvent.click(within(newFilesAlert).getByText(/Dismiss/i));
+
+ expect(queryByTestId('has-new-files-alert')).toBeNull();
+ });
+
+ it('displays a notification about conflicting errors after pasting a component', async () => {
+ const {
+ queryByTestId, getByTestId, getByRole,
+ } = render();
+
+ axiosMock
+ .onGet(getCourseUnitApiUrl(courseId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ enable_copy_paste_units: true,
+ });
+
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
+
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...courseSectionVerticalMock,
+ user_clipboard: clipboardUnit,
+ });
+
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(copyToClipboard(blockId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
+
+ const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
+ const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
+ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
+ ...updatedAncestorsChild.child_info.children,
+ courseUnitMock,
+ ]);
+
+ axiosMock
+ .onPost(postXBlockBaseApiUrl(postXBlockBody))
+ .reply(200, clipboardMockResponse);
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...updatedCourseSectionVerticalData,
+ });
+
+ global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
+ const conflictingErrorsAlert = getByTestId('has-conflicting-errors-alert');
+
+ expect(within(conflictingErrorsAlert)
+ .getByText(pasteNotificationsMessages.hasConflictingErrorsTitle.defaultMessage)).toBeInTheDocument();
+ expect(within(conflictingErrorsAlert)
+ .getByText(pasteNotificationsMessages.hasConflictingErrorsDescription.defaultMessage)).toBeInTheDocument();
+ expect(within(conflictingErrorsAlert)
+ .getByText(pasteNotificationsMessages.hasConflictingErrorsButtonText.defaultMessage)).toBeInTheDocument();
+ clipboardMockResponse.staticFileNotices.conflictingFiles.forEach((fileName) => {
+ expect(within(conflictingErrorsAlert).getByText(fileName)).toBeInTheDocument();
+ });
+
+ userEvent.click(within(conflictingErrorsAlert).getByText(/Dismiss/i));
+
+ expect(queryByTestId('has-conflicting-errors-alert')).toBeNull();
+ });
+
+ it('displays a notification about error files after pasting a component', async () => {
+ const {
+ queryByTestId, getByTestId, getByRole,
+ } = render();
+
+ axiosMock
+ .onGet(getCourseUnitApiUrl(courseId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ enable_copy_paste_units: true,
+ });
+
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
+
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...courseSectionVerticalMock,
+ user_clipboard: clipboardUnit,
+ });
+
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(copyToClipboard(blockId), store.dispatch);
+
+ userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
+
+ const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
+ const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
+ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
+ ...updatedAncestorsChild.child_info.children,
+ courseUnitMock,
+ ]);
+
+ axiosMock
+ .onPost(postXBlockBaseApiUrl(postXBlockBody))
+ .reply(200, clipboardMockResponse);
+ axiosMock
+ .onGet(getCourseSectionVerticalApiUrl(blockId))
+ .reply(200, {
+ ...updatedCourseSectionVerticalData,
+ });
+
+ global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
+ await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
+ await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
+ const errorFilesAlert = getByTestId('has-error-files-alert');
+
+ expect(within(errorFilesAlert)
+ .getByText(pasteNotificationsMessages.hasErrorsTitle.defaultMessage)).toBeInTheDocument();
+ expect(within(errorFilesAlert)
+ .getByText(pasteNotificationsMessages.hasErrorsDescription.defaultMessage)).toBeInTheDocument();
+
+ userEvent.click(within(errorFilesAlert).getByText(/Dismiss/i));
+
+ expect(queryByTestId('has-error-files')).toBeNull();
+ });
+
+ it('should hide the "Paste component" block if canPasteComponent is false', async () => {
+ const { queryByText, queryByRole } = render();
+
+ axiosMock
+ .onGet(getCourseVerticalChildrenApiUrl(blockId))
+ .reply(200, {
+ ...courseVerticalChildrenMock,
+ canPasteComponent: false,
+ });
+
+ await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
+
+ expect(queryByRole('button', {
+ name: pasteComponentMessages.pasteComponentButtonText.defaultMessage,
+ })).not.toBeInTheDocument();
+ expect(queryByText(
+ pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage,
+ )).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/src/course-unit/__mocks__/clipboardResponse.js b/src/course-unit/__mocks__/clipboardResponse.js
new file mode 100644
index 0000000000..30a4248c1b
--- /dev/null
+++ b/src/course-unit/__mocks__/clipboardResponse.js
@@ -0,0 +1,9 @@
+module.exports = {
+ locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
+ courseKey: 'course-v1:edX+L153+3T2023',
+ staticFileNotices: {
+ newFiles: ['new_file_1', 'new_file_2', 'new_file_3'],
+ conflictingFiles: ['conflicting_file_1', 'conflicting_file_2', 'conflicting_file_3'],
+ errorFiles: ['error_file_1', 'error_file_2', 'error_file_3'],
+ },
+};
diff --git a/src/course-unit/__mocks__/clipboardUnit.js b/src/course-unit/__mocks__/clipboardUnit.js
new file mode 100644
index 0000000000..d181c94ac6
--- /dev/null
+++ b/src/course-unit/__mocks__/clipboardUnit.js
@@ -0,0 +1,16 @@
+module.exports = {
+ 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',
+};
diff --git a/src/course-unit/__mocks__/clipboardXBlock.js b/src/course-unit/__mocks__/clipboardXBlock.js
new file mode 100644
index 0000000000..621044e494
--- /dev/null
+++ b/src/course-unit/__mocks__/clipboardXBlock.js
@@ -0,0 +1,16 @@
+module.exports = {
+ 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',
+};
diff --git a/src/course-unit/__mocks__/courseVerticalChildren.js b/src/course-unit/__mocks__/courseVerticalChildren.js
index a6d8102dc5..32bd8272b6 100644
--- a/src/course-unit/__mocks__/courseVerticalChildren.js
+++ b/src/course-unit/__mocks__/courseVerticalChildren.js
@@ -144,4 +144,5 @@ module.exports = {
},
],
isPublished: false,
+ canPasteComponent: true,
};
diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js
index d8c220b7a4..88072ae83e 100644
--- a/src/course-unit/__mocks__/index.js
+++ b/src/course-unit/__mocks__/index.js
@@ -3,3 +3,6 @@ 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.jsx b/src/course-unit/clipboard/hooks/useClipboard.jsx
new file mode 100644
index 0000000000..0d0c6a82de
--- /dev/null
+++ b/src/course-unit/clipboard/hooks/useClipboard.jsx
@@ -0,0 +1,52 @@
+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) => {
+ 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
+ const refreshPasteButton = (data) => {
+ const isPasteable = canEdit && data?.content && data.content.status !== CLIPBOARD_STATUS.expired;
+ const isPasteableXBlock = isPasteable && !NOT_XBLOCK_TYPES.includes(data.content.blockType);
+ const isPasteableUnit = isPasteable && data.content.blockType === 'vertical';
+
+ setShowPasteXBlock(!!isPasteableXBlock);
+ setShowPasteUnit(!!isPasteableUnit);
+ };
+
+ useEffect(() => {
+ // Handle updates to clipboard data
+ if (canEdit) {
+ refreshPasteButton(clipboardData);
+ setSharedClipboardData(clipboardData);
+ clipboardBroadcastChannel.postMessage(clipboardData);
+ } else {
+ setShowPasteXBlock(false);
+ setShowPasteUnit(false);
+ }
+ }, [clipboardData, canEdit, clipboardBroadcastChannel]);
+
+ useEffect(() => {
+ // Handle messages from the broadcast channel
+ clipboardBroadcastChannel.onmessage = (event) => {
+ setSharedClipboardData(event.data);
+ refreshPasteButton(event.data);
+ };
+
+ // Cleanup function for the BroadcastChannel when the hook is unmounted
+ return () => {
+ clipboardBroadcastChannel.close();
+ };
+ }, [clipboardBroadcastChannel]);
+
+ return { showPasteUnit, showPasteXBlock, sharedClipboardData };
+};
+
+export default useCopyToClipboard;
diff --git a/src/course-unit/clipboard/hooks/useClipboard.test.jsx b/src/course-unit/clipboard/hooks/useClipboard.test.jsx
new file mode 100644
index 0000000000..049cd52477
--- /dev/null
+++ b/src/course-unit/clipboard/hooks/useClipboard.test.jsx
@@ -0,0 +1,121 @@
+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
new file mode 100644
index 0000000000..4b2f009321
--- /dev/null
+++ b/src/course-unit/clipboard/index.js
@@ -0,0 +1,3 @@
+export { default as PasteComponent } from './paste-component';
+export { default as PasteNotificationAlert } from './paste-notification';
+export { default as useCopyToClipboard } from './hooks/useClipboard';
diff --git a/src/course-unit/clipboard/paste-component/PasteComponent.scss b/src/course-unit/clipboard/paste-component/PasteComponent.scss
new file mode 100644
index 0000000000..c68ed4e4c6
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/PasteComponent.scss
@@ -0,0 +1,46 @@
+.whats-in-clipboard {
+ cursor: help;
+ width: fit-content;
+ margin-left: auto;
+
+ .whats-in-clipboard-icon {
+ width: 1.125rem;
+ height: 1.125rem;
+ margin-bottom: 1px;
+ }
+
+ .whats-in-clipboard-text {
+ font-size: $font-size-sm;
+ }
+}
+
+
+.clipboard-popover {
+ min-width: 21.25rem;
+
+ .clipboard-popover-title {
+ &:hover {
+ text-decoration: none;
+ color: initial;
+ }
+
+ &.popover-header {
+ border: none;
+ }
+
+ .clipboard-popover-icon {
+ float: right;
+ }
+ }
+
+ .clipboard-popover-detail-block-type {
+ display: block;
+ font-size: $font-size-sm;
+ line-height: 1.313rem;
+ color: $gray-700;
+ }
+
+ .clipboard-popover-detail-course-name {
+ font-style: italic;
+ }
+}
diff --git a/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx b/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx
new file mode 100644
index 0000000000..197c09904d
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx
@@ -0,0 +1,33 @@
+import PropsTypes from 'prop-types';
+import { useParams } from 'react-router-dom';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Button } from '@openedx/paragon';
+import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
+
+import messages from '../messages';
+
+const PasteComponentButton = ({ handleCreateNewCourseXBlock }) => {
+ const intl = useIntl();
+ const { blockId } = useParams();
+
+ const handlePasteXBlockComponent = () => {
+ handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
+ };
+
+ return (
+
+ );
+};
+
+PasteComponentButton.propTypes = {
+ handleCreateNewCourseXBlock: PropsTypes.func.isRequired,
+};
+
+export default PasteComponentButton;
diff --git a/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx b/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx
new file mode 100644
index 0000000000..70193c3eae
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Icon, Popover, Stack } from '@openedx/paragon';
+import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
+
+import messages from '../messages';
+import { clipboardPropsTypes } from '../constants';
+
+const PopoverContent = ({ clipboardData }) => {
+ const intl = useIntl();
+ const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
+
+ return (
+
+
+
+ {content.displayName}
+ {sourceEditUrl && (
+
+ )}
+
+
+
+ {content.blockTypeDisplay}
+
+ {intl.formatMessage(messages.popoverContentText)}
+
+ {sourceContextTitle}
+
+
+
+
+ );
+};
+
+PopoverContent.propTypes = {
+ clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired,
+};
+
+export default PopoverContent;
diff --git a/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx
new file mode 100644
index 0000000000..939dcfa2d5
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx
@@ -0,0 +1,58 @@
+import { useRef } from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Icon } from '@openedx/paragon';
+import { Question as QuestionIcon } from '@openedx/paragon/icons';
+
+import messages from '../messages';
+
+const WhatsInClipboard = ({
+ handlePopoverToggle, togglePopover, popoverElementRef,
+}) => {
+ const intl = useIntl();
+ const triggerElementRef = useRef(null);
+
+ const handleKeyDown = ({ key }) => {
+ if (key === 'Tab') {
+ popoverElementRef.current.focus();
+ handlePopoverToggle(true);
+ }
+ };
+
+ return (
+ handlePopoverToggle(true)}
+ onMouseLeave={() => handlePopoverToggle(false)}
+ onFocus={() => togglePopover(true)}
+ onBlur={() => togglePopover(false)}
+ >
+
+
+ {intl.formatMessage(messages.pasteComponentWhatsInClipboardText)}
+
+
+ );
+};
+
+WhatsInClipboard.propTypes = {
+ handlePopoverToggle: PropTypes.func.isRequired,
+ togglePopover: PropTypes.func.isRequired,
+ popoverElementRef: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
+ ]).isRequired,
+};
+
+export default WhatsInClipboard;
diff --git a/src/course-unit/clipboard/paste-component/components/index.js b/src/course-unit/clipboard/paste-component/components/index.js
new file mode 100644
index 0000000000..86980f4b9b
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/components/index.js
@@ -0,0 +1,3 @@
+export { default as WhatsInClipboard } from './WhatsInClipboard';
+export { default as PasteComponentButton } from './PasteComponentButton';
+export { default as PopoverContent } from './PopoverContent';
diff --git a/src/course-unit/clipboard/paste-component/constants.js b/src/course-unit/clipboard/paste-component/constants.js
new file mode 100644
index 0000000000..454f332c84
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/constants.js
@@ -0,0 +1,12 @@
+import PropTypes from 'prop-types';
+
+export const clipboardPropsTypes = {
+ sourceEditUrl: PropTypes.string.isRequired,
+ content: PropTypes.shape({
+ displayName: PropTypes.string.isRequired,
+ blockTypeDisplay: PropTypes.string.isRequired,
+ }).isRequired,
+ sourceContextTitle: PropTypes.string.isRequired,
+};
+
+export const OVERLAY_TRIGGERS = ['hover', 'focus'];
diff --git a/src/course-unit/clipboard/paste-component/index.jsx b/src/course-unit/clipboard/paste-component/index.jsx
new file mode 100644
index 0000000000..ab140bf383
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/index.jsx
@@ -0,0 +1,61 @@
+import { useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import { OverlayTrigger, Popover } from '@openedx/paragon';
+
+import { PopoverContent, PasteComponentButton, WhatsInClipboard } from './components';
+import { clipboardPropsTypes, OVERLAY_TRIGGERS } from './constants';
+
+const PasteComponent = ({ handleCreateNewCourseXBlock, clipboardData }) => {
+ const [showPopover, togglePopover] = useState(false);
+ const popoverElementRef = useRef(null);
+
+ const handlePopoverToggle = (isOpen) => togglePopover(isOpen);
+
+ const renderPopover = (props) => (
+
+
handlePopoverToggle(true)}
+ onMouseLeave={() => handlePopoverToggle(false)}
+ onFocus={() => handlePopoverToggle(true)}
+ onBlur={() => handlePopoverToggle(false)}
+ {...props}
+ >
+ {clipboardData && (
+
+ )}
+
+
+ );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+PasteComponent.propTypes = {
+ handleCreateNewCourseXBlock: PropTypes.func.isRequired,
+ clipboardData: PropTypes.shape(clipboardPropsTypes),
+};
+
+PasteComponent.defaultProps = {
+ clipboardData: null,
+};
+
+export default PasteComponent;
diff --git a/src/course-unit/clipboard/paste-component/messages.js b/src/course-unit/clipboard/paste-component/messages.js
new file mode 100644
index 0000000000..1463a6746f
--- /dev/null
+++ b/src/course-unit/clipboard/paste-component/messages.js
@@ -0,0 +1,18 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ pasteComponentButtonText: {
+ id: 'course-authoring.course-unit.paste-component.btn.text',
+ defaultMessage: 'Paste component',
+ },
+ popoverContentText: {
+ id: 'course-authoring.course-unit.popover.content.text',
+ defaultMessage: 'From:',
+ },
+ pasteComponentWhatsInClipboardText: {
+ id: 'course-authoring.course-unit.paste-component.whats-in-clipboard.text',
+ defaultMessage: "What's in my clipboard?",
+ },
+});
+
+export default messages;
diff --git a/src/course-unit/clipboard/paste-notification/components/ActionButton.jsx b/src/course-unit/clipboard/paste-notification/components/ActionButton.jsx
new file mode 100644
index 0000000000..bc9dcc6daf
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/components/ActionButton.jsx
@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+import { Button } from '@openedx/paragon';
+import { Link } from 'react-router-dom';
+import { getConfig } from '@edx/frontend-platform';
+
+const ActionButton = ({ courseId, title }) => (
+
+);
+
+ActionButton.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+};
+
+export default ActionButton;
diff --git a/src/course-unit/clipboard/paste-notification/components/AlertContent.jsx b/src/course-unit/clipboard/paste-notification/components/AlertContent.jsx
new file mode 100644
index 0000000000..f5e8c55a98
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/components/AlertContent.jsx
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+
+import { FILE_LIST_DEFAULT_VALUE } from '../constants';
+import FileList from './FileList';
+
+const AlertContent = ({ fileList, text }) => (
+ <>
+ {text}
+
+ >
+);
+
+AlertContent.propTypes = {
+ fileList: PropTypes.arrayOf(PropTypes.string),
+ text: PropTypes.string.isRequired,
+};
+
+AlertContent.defaultProps = {
+ fileList: FILE_LIST_DEFAULT_VALUE,
+};
+
+export default AlertContent;
diff --git a/src/course-unit/clipboard/paste-notification/components/FileList.jsx b/src/course-unit/clipboard/paste-notification/components/FileList.jsx
new file mode 100644
index 0000000000..f3f9e3beaa
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/components/FileList.jsx
@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+
+import { FILE_LIST_DEFAULT_VALUE } from '../constants';
+
+const FileList = ({ fileList }) => (
+
+ {fileList.map((fileName) => (
+ - {fileName}
+ ))}
+
+);
+
+FileList.propTypes = {
+ fileList: PropTypes.arrayOf(PropTypes.string),
+};
+
+FileList.defaultProps = {
+ fileList: FILE_LIST_DEFAULT_VALUE,
+};
+
+export default FileList;
diff --git a/src/course-unit/clipboard/paste-notification/components/index.js b/src/course-unit/clipboard/paste-notification/components/index.js
new file mode 100644
index 0000000000..ccee5ba494
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/components/index.js
@@ -0,0 +1,3 @@
+export { default as AlertContent } from './AlertContent';
+export { default as FileList } from './FileList';
+export { default as ActionButton } from './ActionButton';
diff --git a/src/course-unit/clipboard/paste-notification/constants.js b/src/course-unit/clipboard/paste-notification/constants.js
new file mode 100644
index 0000000000..a44ab2276c
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/constants.js
@@ -0,0 +1,7 @@
+export const FILE_LIST_DEFAULT_VALUE = [];
+
+export const initialNotificationAlertsState = {
+ conflictingFilesAlert: true,
+ errorFilesAlert: true,
+ newFilesAlert: true,
+};
diff --git a/src/course-unit/clipboard/paste-notification/index.jsx b/src/course-unit/clipboard/paste-notification/index.jsx
new file mode 100644
index 0000000000..260acdd20a
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/index.jsx
@@ -0,0 +1,107 @@
+import { useState } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Error as ErrorIcon,
+ Info as InfoIcon,
+ Warning as WarningIcon,
+} from '@openedx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import AlertMessage from '../../../generic/alert-message';
+import { ActionButton, AlertContent } from './components';
+import { getAlertStatus } from './utils';
+import { initialNotificationAlertsState } from './constants';
+import messages from './messages';
+
+const PastNotificationAlert = ({ staticFileNotices, courseId }) => {
+ const intl = useIntl();
+ const [notificationAlerts, toggleNotificationAlerts] = useState(initialNotificationAlertsState);
+ const { conflictingFiles, errorFiles, newFiles } = staticFileNotices;
+
+ const hasConflictingErrors = getAlertStatus(conflictingFiles, 'conflictingFilesAlert', notificationAlerts);
+ const hasErrorFiles = getAlertStatus(errorFiles, 'errorFilesAlert', notificationAlerts);
+ const hasNewFiles = getAlertStatus(newFiles, 'newFilesAlert', notificationAlerts);
+
+ const handleCloseNotificationAlert = (alertKey) => {
+ toggleNotificationAlerts((prevAlerts) => ({
+ ...prevAlerts,
+ [alertKey]: false,
+ }));
+ };
+
+ return (
+ <>
+ {hasConflictingErrors && (
+ handleCloseNotificationAlert('conflictingFilesAlert')}
+ description={(
+
+ )}
+ variant="warning"
+ icon={WarningIcon}
+ dismissible
+ actions={[
+ ,
+ ]}
+ />
+ )}
+ {hasErrorFiles && (
+ handleCloseNotificationAlert('errorFilesAlert')}
+ description={(
+
+ )}
+ variant="danger"
+ icon={ErrorIcon}
+ dismissible
+ />
+ )}
+ {hasNewFiles && (
+ handleCloseNotificationAlert('newFilesAlert')}
+ description={(
+
+ )}
+ variant="info"
+ icon={InfoIcon}
+ dismissible
+ actions={[
+ ,
+ ]}
+ />
+ )}
+ >
+ );
+};
+
+PastNotificationAlert.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ staticFileNotices: PropTypes.shape({
+ conflictingFiles: PropTypes.arrayOf(PropTypes.string),
+ errorFiles: PropTypes.arrayOf(PropTypes.string),
+ newFiles: PropTypes.arrayOf(PropTypes.string),
+ }).isRequired,
+};
+
+export default PastNotificationAlert;
diff --git a/src/course-unit/clipboard/paste-notification/messages.js b/src/course-unit/clipboard/paste-notification/messages.js
new file mode 100644
index 0000000000..2786256a87
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/messages.js
@@ -0,0 +1,38 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ hasConflictingErrorsTitle: {
+ id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.title',
+ defaultMessage: 'Files need to be updated manually.',
+ },
+ hasConflictingErrorsDescription: {
+ id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.description',
+ defaultMessage: 'The following files must be updated manually for components to work as intended:',
+ },
+ hasConflictingErrorsButtonText: {
+ id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text',
+ defaultMessage: 'Upload files',
+ },
+ hasErrorsTitle: {
+ id: 'course-authoring.course-unit.paste-notification.has-errors.title',
+ defaultMessage: 'Some errors occurred',
+ },
+ hasErrorsDescription: {
+ id: 'course-authoring.course-unit.paste-notification.has-errors.description',
+ defaultMessage: 'The following required files could not be added to the course:',
+ },
+ hasNewFilesTitle: {
+ id: 'course-authoring.course-unit.paste-notification.has-new-files.title',
+ defaultMessage: 'New file(s) added to Files & Uploads.',
+ },
+ hasNewFilesDescription: {
+ id: 'course-authoring.course-unit.paste-notification.has-new-files.description',
+ defaultMessage: 'The following required files were imported to this course:',
+ },
+ hasNewFilesButtonText: {
+ id: 'course-authoring.course-unit.paste-notification.has-new-files.button.text',
+ defaultMessage: 'View files',
+ },
+});
+
+export default messages;
diff --git a/src/course-unit/clipboard/paste-notification/utils.js b/src/course-unit/clipboard/paste-notification/utils.js
new file mode 100644
index 0000000000..d8d1122677
--- /dev/null
+++ b/src/course-unit/clipboard/paste-notification/utils.js
@@ -0,0 +1,12 @@
+/**
+ * Gets the status of an alert based on the length of a fileList.
+ *
+ * @param {Array} fileList - The list of files.
+ * @param {string} alertKey - The key associated with the alert in the alertState.
+ * @param {Object} alertState - The state object containing alert statuses.
+ * @returns {boolean|null} - The status of the alert. Returns `true` if the fileList has length,
+ * `false` if it does not, and `null` if fileList is not defined.
+ */
+// eslint-disable-next-line import/prefer-default-export
+export const getAlertStatus = (fileList, alertKey, alertState) => (
+ fileList?.length ? fileList && alertState[alertKey] : null);
diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js
index b7e7bf5c6b..6ca687d01f 100644
--- a/src/course-unit/constants.js
+++ b/src/course-unit/constants.js
@@ -18,6 +18,22 @@ import addComponentMessages from './add-component/messages';
export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock'];
+export const NOT_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course'];
+
+export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
+
+/**
+ * Enum for clipboard status.
+ * @readonly
+ * @enum {string}
+ */
+export const CLIPBOARD_STATUS = {
+ loading: 'loading',
+ ready: 'ready',
+ expired: 'expired',
+ error: 'error',
+};
+
export const COMPONENT_TYPES = {
advanced: 'advanced',
discussion: 'discussion',
diff --git a/src/course-unit/course-sequence/CourseSequence.scss b/src/course-unit/course-sequence/CourseSequence.scss
index 21bf490d15..9a0a34004c 100644
--- a/src/course-unit/course-sequence/CourseSequence.scss
+++ b/src/course-unit/course-sequence/CourseSequence.scss
@@ -35,7 +35,7 @@
min-width: 0;
}
- .sequence-navigation-tabs .btn:not(.sequence-navigation-tabs-new-unit-btn) {
+ .sequence-navigation-tabs .btn:not(.sequence-navigation-tabs-action-btn) {
flex-basis: 100%;
min-width: 2rem;
}
@@ -63,7 +63,7 @@
.sequence-navigation-prev-btn,
.sequence-navigation-next-btn,
- .sequence-navigation-tabs-new-unit-btn {
+ .sequence-navigation-tabs-action-btn {
min-width: 12.5rem;
}
diff --git a/src/course-unit/course-sequence/Sequence.jsx b/src/course-unit/course-sequence/Sequence.jsx
index 7729bba5a6..f8a7ea007f 100644
--- a/src/course-unit/course-sequence/Sequence.jsx
+++ b/src/course-unit/course-sequence/Sequence.jsx
@@ -14,6 +14,7 @@ const Sequence = ({
sequenceId,
unitId,
handleCreateNewCourseXBlock,
+ showPasteUnit,
}) => {
const intl = useIntl();
const { IN_PROGRESS, FAILED, SUCCESSFUL } = RequestStatus;
@@ -28,6 +29,7 @@ const Sequence = ({
unitId={unitId}
courseId={courseId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
+ showPasteUnit={showPasteUnit}
/>
@@ -61,6 +63,7 @@ Sequence.propTypes = {
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
+ showPasteUnit: PropTypes.bool.isRequired,
};
Sequence.defaultProps = {
diff --git a/src/course-unit/course-sequence/messages.js b/src/course-unit/course-sequence/messages.js
index 7c33787077..0f7019ae20 100644
--- a/src/course-unit/course-sequence/messages.js
+++ b/src/course-unit/course-sequence/messages.js
@@ -29,6 +29,10 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.sequence.navigation.menu',
defaultMessage: '{current} of {total}',
},
+ pasteAsNewUnitLink: {
+ id: 'course-authoring.course-unit.sequence.navigation.menu.copy-unit.past-unit-link',
+ defaultMessage: 'Paste as new unit',
+ },
});
export default messages;
diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx
index a2fbe55d4e..0fa15fa29e 100644
--- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx
+++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx
@@ -25,6 +25,7 @@ const SequenceNavigation = ({
sequenceId,
className,
handleCreateNewCourseXBlock,
+ showPasteUnit,
}) => {
const sequenceStatus = useSelector(getSequenceStatus);
const {
@@ -45,6 +46,7 @@ const SequenceNavigation = ({
unitIds={sequence.unitIds || []}
unitId={unitId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
+ showPasteUnit={showPasteUnit}
/>
);
};
@@ -110,6 +112,7 @@ SequenceNavigation.propTypes = {
className: PropTypes.string,
sequenceId: PropTypes.string,
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
+ showPasteUnit: PropTypes.bool.isRequired,
};
SequenceNavigation.defaultProps = {
diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx
index e601ce2f3b..2e2923d272 100644
--- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx
+++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx
@@ -1,12 +1,18 @@
import PropTypes from 'prop-types';
import { Button, Dropdown } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { Plus as PlusIcon } from '@openedx/paragon/icons/';
+import { Plus as PlusIcon, ContentPasteGo as ContentPasteGoIcon } from '@openedx/paragon/icons/';
import messages from '../messages';
import UnitButton from './UnitButton';
-const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => {
+const SequenceNavigationDropdown = ({
+ unitId,
+ unitIds,
+ handleAddNewSequenceUnit,
+ handlePasteNewSequenceUnit,
+ showPasteUnit,
+}) => {
const intl = useIntl();
return (
@@ -32,10 +38,20 @@ const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => {
as={Dropdown.Item}
variant="outline-primary"
iconBefore={PlusIcon}
- onClick={handleClick}
+ onClick={handleAddNewSequenceUnit}
>
{intl.formatMessage(messages.newUnitBtnText)}
+ {showPasteUnit && (
+
+ )}
);
@@ -44,7 +60,9 @@ const SequenceNavigationDropdown = ({ unitId, unitIds, handleClick }) => {
SequenceNavigationDropdown.propTypes = {
unitId: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
- handleClick: PropTypes.func.isRequired,
+ handleAddNewSequenceUnit: PropTypes.func.isRequired,
+ handlePasteNewSequenceUnit: PropTypes.func.isRequired,
+ showPasteUnit: PropTypes.bool.isRequired,
};
export default SequenceNavigationDropdown;
diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx
index 7565a8c0d1..77372988ff 100644
--- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx
+++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx
@@ -2,7 +2,7 @@ import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { Button } from '@openedx/paragon';
-import { Plus as PlusIcon } from '@openedx/paragon/icons';
+import { Plus as PlusIcon, ContentPasteGo as ContentPasteGoIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice';
@@ -12,7 +12,9 @@ import { useIndexOfLastVisibleChild } from '../hooks';
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
import UnitButton from './UnitButton';
-const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock }) => {
+const SequenceNavigationTabs = ({
+ unitIds, unitId, handleCreateNewCourseXBlock, showPasteUnit,
+}) => {
const intl = useIntl();
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -34,6 +36,14 @@ const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock }
});
};
+ const handlePasteNewSequenceUnit = () => {
+ dispatch(updateQueryPendingStatus(true));
+ handleCreateNewCourseXBlock({ parentLocator: sequenceId, stagedContent: 'clipboard' }, ({ courseKey, locator }) => {
+ navigate(`/course/${courseKey}/container/${locator}/${sequenceId}`, courseId);
+ dispatch(changeEditTitleFormOpen(true));
+ }, unitId);
+ };
+
return (
@@ -49,20 +59,32 @@ const SequenceNavigationTabs = ({ unitIds, unitId, handleCreateNewCourseXBlock }
/>
))}
+ {showPasteUnit && (
+
+ )}
{shouldDisplayDropdown && (
)}
@@ -73,6 +95,7 @@ SequenceNavigationTabs.propTypes = {
unitId: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
+ showPasteUnit: PropTypes.bool.isRequired,
};
export default SequenceNavigationTabs;
diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx
index 3f885692bc..d9c8ba0525 100644
--- a/src/course-unit/course-xblock/CourseXBlock.jsx
+++ b/src/course-unit/course-xblock/CourseXBlock.jsx
@@ -1,18 +1,19 @@
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
import {
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
} from '@openedx/paragon';
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { useSelector } from 'react-redux';
-import { useNavigate } from 'react-router-dom';
+import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import { scrollToElement } from '../../course-outline/utils';
import { COURSE_BLOCK_NAMES } from '../../constants';
-import { getCourseId } from '../data/selectors';
+import { copyToClipboard } from '../data/thunk';
import { COMPONENT_TYPES } from '../constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
@@ -24,7 +25,9 @@ const CourseXBlock = ({
const courseXBlockElementRef = useRef(null);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
+ const dispatch = useDispatch();
const navigate = useNavigate();
+ const canEdit = useSelector(getCanEdit);
const courseId = useSelector(getCourseId);
const intl = useIntl();
@@ -90,15 +93,17 @@ const CourseXBlock = ({
iconAs={Icon}
/>
-
- {intl.formatMessage(messages.blockLabelButtonCopy)}
-
unitXBlockActions.handleDuplicate(id)}>
{intl.formatMessage(messages.blockLabelButtonDuplicate)}
{intl.formatMessage(messages.blockLabelButtonMove)}
+ {canEdit && (
+ dispatch(copyToClipboard(id))}>
+ {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
+
+ )}
{intl.formatMessage(messages.blockLabelButtonManageAccess)}
diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js
index 1b78bfcc91..3e1652de19 100644
--- a/src/course-unit/course-xblock/messages.js
+++ b/src/course-unit/course-xblock/messages.js
@@ -26,6 +26,10 @@ const messages = defineMessages({
defaultMessage: 'Move',
description: 'The xblock move button text',
},
+ blockLabelButtonCopyToClipboard: {
+ id: 'course-authoring.course-unit.xblock.button.copyToClipboard.label',
+ defaultMessage: 'Copy to clipboard',
+ },
blockLabelButtonManageAccess: {
id: 'course-authoring.course-unit.xblock.button.manageAccess.label',
defaultMessage: 'Manage access',
diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js
index 3ec12cef43..acbafdebcb 100644
--- a/src/course-unit/data/api.js
+++ b/src/course-unit/data/api.js
@@ -11,6 +11,7 @@ export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/con
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
+export const getClipboardUrl = () => `${getStudioBaseUrl()}/api/content-staging/v1/clipboard/`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
@@ -60,13 +61,13 @@ export async function getCourseSectionVerticalData(unitId) {
* @param {Object} options - The options for creating the XBlock.
* @param {string} options.type - The type of the XBlock.
* @param {string} [options.category] - The category of the XBlock. Defaults to the type if not provided.
- * @param {string} options.parentLocator - The parent locator of the XBlock.
- * @param {string} [options.displayName] - The display name for the XBlock.
- * @param {string} [options.boilerplate] - The boilerplate for the XBlock.
- * @returns {Promise
-
- );
-
- return (
- <>
-
-
-
-
- {intl.formatMessage(messages.clipboardContentLabel)}
-
-
- >
- );
-};
-
-PasteButton.propTypes = {
- text: PropTypes.string.isRequired,
- blockType: PropTypes.string.isRequired,
- onClick: PropTypes.func.isRequired,
-};
-
-export default PasteButton;
diff --git a/src/course-outline/paste-button/PasteButton.scss b/src/course-outline/paste-button/PasteButton.scss
deleted file mode 100644
index 04d4491816..0000000000
--- a/src/course-outline/paste-button/PasteButton.scss
+++ /dev/null
@@ -1,20 +0,0 @@
-// adds bottom arrow to popup link
-.popup-link {
- position: relative;
-
- &::after {
- content: "";
- position: absolute;
- top: 100%;
- left: 50%;
- width: 0;
- height: 0;
- border-top: solid .5rem white;
- border-left: solid .5rem transparent;
- border-right: solid .5rem transparent;
- }
-}
-
-.cursor-help {
- cursor: help !important;
-}
diff --git a/src/course-outline/paste-button/messages.js b/src/course-outline/paste-button/messages.js
deleted file mode 100644
index 0576b500f6..0000000000
--- a/src/course-outline/paste-button/messages.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineMessages } from '@edx/frontend-platform/i18n';
-
-const messages = defineMessages({
- clipboardContentFromLabel: {
- id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.from-label',
- defaultMessage: 'From: ',
- },
- clipboardContentLabel: {
- id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.label',
- defaultMessage: 'What\'s in my clipboard?',
- },
-});
-
-export default messages;
diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx
index 7814eb99a1..7f31b4b1fe 100644
--- a/src/course-outline/subsection-card/SubsectionCard.jsx
+++ b/src/course-outline/subsection-card/SubsectionCard.jsx
@@ -13,13 +13,12 @@ import { isEmpty } from 'lodash';
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 SortableItem from '../drag-helper/SortableItem';
import { DragContext } from '../drag-helper/DragContextProvider';
+import { useCopyToClipboard, PasteComponent } 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';
@@ -50,6 +49,7 @@ const SubsectionCard = ({
const isScrolledToElement = locatorId === subsection.id;
const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'subsection';
+ const { sharedClipboardData, showPasteUnit } = useCopyToClipboard();
const {
id,
@@ -66,7 +66,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.
const moveUpDetails = getPossibleMoves(index, -1);
const moveDownDetails = getPossibleMoves(index, 1);
actions.allowMoveUp = !isEmpty(moveUpDetails);
@@ -217,10 +217,11 @@ const SubsectionCard = ({
>
{intl.formatMessage(messages.newUnitButton)}
- {enableCopyPasteUnits && (
-
)}
diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx
index d76d305aed..167be766a9 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,
@@ -26,6 +25,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 f6a2c5b0e7..57f5e52174 100644
--- a/src/course-unit/CourseUnit.jsx
+++ b/src/course-unit/CourseUnit.jsx
@@ -11,6 +11,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 { PasteComponent } from '../generic/clipboard';
import ProcessingNotification from '../generic/processing-notification';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
@@ -27,7 +28,7 @@ import messages from './messages';
import PublishControls from './sidebar/PublishControls';
import LocationInfo from './sidebar/LocationInfo';
import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
-import { PasteNotificationAlert, PasteComponent } from './clipboard';
+import { PasteNotificationAlert } from './clipboard';
const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
@@ -158,7 +159,8 @@ const CourseUnit = ({ courseId }) => {
{showPasteXBlock && canPasteComponent && (
)}
diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss
index b46ba52d6a..6e380bf9d2 100644
--- a/src/course-unit/CourseUnit.scss
+++ b/src/course-unit/CourseUnit.scss
@@ -4,4 +4,3 @@
@import "./course-xblock/CourseXBlock";
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";
-@import "./clipboard/paste-component/PasteComponent";
diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx
index 65dfd00054..31d2312130 100644
--- a/src/course-unit/CourseUnit.test.jsx
+++ b/src/course-unit/CourseUnit.test.jsx
@@ -17,7 +17,6 @@ import {
postXBlockBaseApiUrl,
} from './data/api';
import {
- copyToClipboard,
createNewCourseXBlock,
deleteUnitItemQuery,
editCourseUnitVisibilityAndData,
@@ -27,8 +26,6 @@ import {
} from './data/thunk';
import initializeStore from '../store';
import {
- clipboardUnit,
- clipboardXBlock,
courseCreateXblockMock,
courseSectionVerticalMock,
courseUnitIndexMock,
@@ -36,9 +33,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 pasteComponentMessages from '../generic/clipboard/paste-component/messages';
import pasteNotificationsMessages from './clipboard/paste-notification/messages';
import headerNavigationsMessages from './header-navigations/messages';
import headerTitleMessages from './header-title/messages';
@@ -51,8 +52,9 @@ import configureModalMessages from '../generic/configure-modal/messages';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
-import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
import messages from './messages';
+import { copyToClipboard } from '../generic/data/thunks';
+import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
let axiosMock;
let store;
@@ -1039,7 +1041,7 @@ describe('', () => {
await waitFor(() => {
expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull();
- expect(queryByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeNull();
+ expect(queryByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeNull();
});
axiosMock
@@ -1072,10 +1074,10 @@ describe('', () => {
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
- expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument();
+ expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
const whatsInClipboardText = getByText(
- pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage,
+ pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage,
);
userEvent.hover(whatsInClipboardText);
@@ -1129,7 +1131,7 @@ describe('', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
- userEvent.click(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage }));
+ userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage }));
expect(getAllByTestId('course-xblock')).toHaveLength(2);
@@ -1174,7 +1176,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.pasteButtonText.defaultMessage })).toBeInTheDocument();
});
it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => {
@@ -1438,10 +1440,10 @@ describe('', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
expect(queryByRole('button', {
- name: pasteComponentMessages.pasteComponentButtonText.defaultMessage,
+ name: messages.pasteButtonText.defaultMessage,
})).not.toBeInTheDocument();
expect(queryByText(
- pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage,
+ pasteComponentMessages.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/index.js b/src/course-unit/clipboard/index.js
index 4b2f009321..22e541cc9e 100644
--- a/src/course-unit/clipboard/index.js
+++ b/src/course-unit/clipboard/index.js
@@ -1,3 +1,2 @@
-export { default as PasteComponent } from './paste-component';
+// 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/clipboard/paste-component/components/PasteComponentButton.jsx b/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx
deleted file mode 100644
index 197c09904d..0000000000
--- a/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import PropsTypes from 'prop-types';
-import { useParams } from 'react-router-dom';
-import { useIntl } from '@edx/frontend-platform/i18n';
-import { Button } from '@openedx/paragon';
-import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
-
-import messages from '../messages';
-
-const PasteComponentButton = ({ handleCreateNewCourseXBlock }) => {
- const intl = useIntl();
- const { blockId } = useParams();
-
- const handlePasteXBlockComponent = () => {
- handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
- };
-
- return (
-
- );
-};
-
-PasteComponentButton.propTypes = {
- handleCreateNewCourseXBlock: PropsTypes.func.isRequired,
-};
-
-export default PasteComponentButton;
diff --git a/src/course-unit/clipboard/paste-component/messages.js b/src/course-unit/clipboard/paste-component/messages.js
deleted file mode 100644
index 1463a6746f..0000000000
--- a/src/course-unit/clipboard/paste-component/messages.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { defineMessages } from '@edx/frontend-platform/i18n';
-
-const messages = defineMessages({
- pasteComponentButtonText: {
- id: 'course-authoring.course-unit.paste-component.btn.text',
- defaultMessage: 'Paste component',
- },
- popoverContentText: {
- id: 'course-authoring.course-unit.popover.content.text',
- defaultMessage: 'From:',
- },
- pasteComponentWhatsInClipboardText: {
- id: 'course-authoring.course-unit.paste-component.whats-in-clipboard.text',
- defaultMessage: "What's in my clipboard?",
- },
-});
-
-export default messages;
diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx
index d9c8ba0525..46ba76e32a 100644
--- a/src/course-unit/course-xblock/CourseXBlock.jsx
+++ b/src/course-unit/course-xblock/CourseXBlock.jsx
@@ -13,7 +13,7 @@ import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import { scrollToElement } from '../../course-outline/utils';
import { COURSE_BLOCK_NAMES } from '../../constants';
-import { copyToClipboard } from '../data/thunk';
+import { copyToClipboard } from '../../generic/data/thunks';
import { COMPONENT_TYPES } from '../constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js
index acbafdebcb..d21a95b786 100644
--- a/src/course-unit/data/api.js
+++ b/src/course-unit/data/api.js
@@ -11,8 +11,6 @@ export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/con
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
-export const getClipboardUrl = () => `${getStudioBaseUrl()}/api/content-staging/v1/clipboard/`;
-
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
/**
@@ -84,29 +82,6 @@ export async function createCourseXblock({
return data;
}
-/**
- * Retrieves user's clipboard.
- * @returns {Promise} - A Promise that resolves clipboard data.
- */
-export async function getClipboard() {
- const { data } = await getAuthenticatedHttpClient()
- .get(getClipboardUrl());
-
- return camelCaseObject(data);
-}
-
-/**
- * Updates user's clipboard.
- * @param {string} usageKey - The ID of the block.
- * @returns {Promise} - A Promise that resolves clipboard data.
- */
-export async function updateClipboard(usageKey) {
- const { data } = await getAuthenticatedHttpClient()
- .post(getClipboardUrl(), { usage_key: usageKey });
-
- return camelCaseObject(data);
-}
-
/**
* Handles the visibility and data of a course unit, such as publishing, resetting to default values,
* and toggling visibility to students.
diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js
index 19d1a2c1b2..e9e98bc0ce 100644
--- a/src/course-unit/data/selectors.js
+++ b/src/course-unit/data/selectors.js
@@ -1,3 +1,7 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+import { RequestStatus } from '../../data/constants';
+
export const getCourseUnitData = (state) => state.courseUnit.unit;
export const getCanEdit = (state) => state.courseUnit.canEdit;
export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices;
@@ -10,4 +14,9 @@ export const getCourseSectionVertical = (state) => state.courseUnit.courseSectio
export const getCourseId = (state) => state.courseDetail.courseId;
export const getSequenceId = (state) => state.courseUnit.sequenceId;
export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren;
-export const getClipboardData = state => state.courseUnit.clipboardData;
+const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
+export const getIsLoading = createSelector(
+ [getLoadingStatuses],
+ loadingStatus => Object.values(loadingStatus)
+ .some((status) => status === RequestStatus.IN_PROGRESS),
+);
diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js
index b1d6490c87..87b60094a4 100644
--- a/src/course-unit/data/slice.js
+++ b/src/course-unit/data/slice.js
@@ -18,7 +18,6 @@ const slice = createSlice({
unit: {},
courseSectionVertical: {},
courseVerticalChildren: {},
- clipboardData: null,
staticFileNotices: {},
},
reducers: {
@@ -98,9 +97,6 @@ const slice = createSlice({
}),
};
},
- updateClipboardData: (state, { payload }) => {
- state.clipboardData = payload;
- },
fetchStaticFileNoticesSuccess: (state, { payload }) => {
state.staticFileNotices = payload;
},
@@ -124,7 +120,6 @@ export const {
updateCourseVerticalChildrenLoadingStatus,
deleteXBlock,
duplicateXBlock,
- updateClipboardData,
fetchStaticFileNoticesSuccess,
} = slice.actions;
diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js
index ae5ea704b7..7ad07b44ab 100644
--- a/src/course-unit/data/thunk.js
+++ b/src/course-unit/data/thunk.js
@@ -1,5 +1,4 @@
import { camelCaseObject } from '@edx/frontend-platform';
-import { logError } from '@edx/frontend-platform/logging';
import {
hideProcessingNotification,
@@ -8,15 +7,13 @@ import {
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '../../generic/model-store';
-import { CLIPBOARD_STATUS } from '../constants';
+import { updateClipboardData } from '../../generic/data/slice';
import {
getCourseUnitData,
editUnitDisplayName,
getCourseSectionVerticalData,
createCourseXblock,
getCourseVerticalChildren,
- updateClipboard,
- getClipboard,
handleCourseUnitVisibilityAndData,
deleteUnitItem,
duplicateUnitItem,
@@ -33,7 +30,6 @@ import {
updateLoadingCourseXblockStatus,
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
- updateClipboardData,
updateQueryPendingStatus,
deleteXBlock,
duplicateXBlock,
@@ -245,35 +241,3 @@ export function duplicateUnitItemQuery(itemId, xblockId) {
}
};
}
-
-export function copyToClipboard(usageKey) {
- const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds
-
- return async (dispatch) => {
- dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
- dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
- dispatch(updateQueryPendingStatus(true));
-
- try {
- let clipboardData = await updateClipboard(usageKey);
-
- while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) {
- // eslint-disable-next-line no-await-in-loop,no-promise-executor-return
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
- clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop
- }
-
- if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) {
- dispatch(updateClipboardData(clipboardData));
- dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
- } else {
- throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`);
- }
- } catch (error) {
- dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
- logError('Error copying to clipboard:', error);
- } finally {
- dispatch(hideProcessingNotification());
- }
- };
-}
diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx
index 97675b58e8..335593db2a 100644
--- a/src/course-unit/hooks.jsx
+++ b/src/course-unit/hooks.jsx
@@ -26,7 +26,7 @@ import {
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
import { PUBLISH_TYPES } from './constants';
-import { useCopyToClipboard } from './clipboard';
+import { useCopyToClipboard } from '../generic/clipboard';
// eslint-disable-next-line import/prefer-default-export
export const useCourseUnit = ({ courseId, blockId }) => {
diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js
index ba27b0fb78..4f0418efe5 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.general.alert.unpublished-version.description',
defaultMessage: 'Note: The last published version of this unit is live. By publishing changes you will change the student experience.',
},
+ pasteButtonText: {
+ 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.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx
index a3e7e03afa..5f78ae7617 100644
--- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx
+++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx
@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Divider } from '../../../../generic/divider';
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
-import { copyToClipboard } from '../../../data/thunk';
+import { copyToClipboard } from '../../../../generic/data/thunks';
import messages from '../../messages';
const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
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..2289968c18 100644
--- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx
+++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx
@@ -8,14 +8,17 @@ import userEvent from '@testing-library/user-event';
import initializeStore from '../../../../store';
import { executeThunk } from '../../../../utils';
-import { getClipboardUrl, getCourseUnitApiUrl } from '../../../data/api';
-import { copyToClipboard, fetchCourseUnitQuery } from '../../../data/thunk';
-import { clipboardUnit, courseUnitIndexMock } from '../../../__mocks__';
+import { clipboardUnit } from '../../../../__mocks__';
+import { getCourseUnitApiUrl } from '../../../data/api';
+import { getClipboardUrl } from '../../../../generic/data/api';
+import { fetchCourseUnitQuery } from '../../../data/thunk';
+import { copyToClipboard } from '../../../../generic/data/thunks';
+import { courseUnitIndexMock } from '../../../__mocks__';
import messages from '../../messages';
import ActionButtons from './ActionButtons';
-jest.mock('../../../data/thunk', () => ({
- ...jest.requireActual('../../../data/thunk'),
+jest.mock('../../../../generic/data/thunks', () => ({
+ ...jest.requireActual('../../../../generic/data/thunks'),
copyToClipboard: jest.fn().mockImplementation(() => () => {}),
}));
diff --git a/src/generic/broadcast-channel/hooks.js b/src/generic/broadcast-channel/hooks.js
deleted file mode 100644
index 230c153d18..0000000000
--- a/src/generic/broadcast-channel/hooks.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import {
- useCallback, useEffect, useMemo, useRef,
-} from 'react';
-import { BroadcastChannel } from 'broadcast-channel';
-
-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 = (data) => onMessageReceived(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 74%
rename from src/course-unit/clipboard/hooks/useClipboard.jsx
rename to src/generic/clipboard/hooks/useCopyToClipboard.js
index 0d0c6a82de..862788e0bb 100644
--- a/src/course-unit/clipboard/hooks/useClipboard.jsx
+++ b/src/generic/clipboard/hooks/useCopyToClipboard.js
@@ -1,15 +1,24 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
+import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
import { getClipboardData } from '../../data/selectors';
-import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
-const useCopyToClipboard = (canEdit) => {
+/**
+ * Custom React hook for managing clipboard functionality.
+ *
+ * @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 = (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({});
+ const clipboardData = useSelector(getClipboardData);
// Function to refresh the paste button's visibility
const refreshPasteButton = (data) => {
diff --git a/src/course-unit/clipboard/hooks/useClipboard.test.jsx b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx
similarity index 85%
rename from src/course-unit/clipboard/hooks/useClipboard.test.jsx
rename to src/generic/clipboard/hooks/useCopyToClipboard.test.jsx
index 049cd52477..2e57f409df 100644
--- a/src/course-unit/clipboard/hooks/useClipboard.test.jsx
+++ b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx
@@ -7,10 +7,10 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
-import { copyToClipboard } from '../../data/thunk';
+import { clipboardUnit, clipboardXBlock } from '../../../__mocks__';
+import { copyToClipboard } from '../../data/thunks';
import { getClipboardUrl } from '../../data/api';
-import { clipboardUnit, clipboardXBlock } from '../../__mocks__';
-import useClipboard from './useClipboard';
+import useCopyToClipboard from './useCopyToClipboard';
let axiosMock;
let store;
@@ -20,6 +20,7 @@ const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
+
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const wrapper = ({ children }) => (
@@ -46,7 +47,7 @@ describe('useCopyToClipboard', () => {
});
it('initializes correctly', () => {
- const { result } = renderHook(() => useClipboard(true), { wrapper });
+ const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(false);
@@ -54,7 +55,7 @@ describe('useCopyToClipboard', () => {
describe('clipboard data update effect', () => {
it('returns falsy flags if canEdit = false', async () => {
- const { result } = renderHook(() => useClipboard(false), { wrapper });
+ const { result } = renderHook(() => useCopyToClipboard(false), { wrapper });
axiosMock
.onPost(getClipboardUrl())
@@ -71,7 +72,7 @@ describe('useCopyToClipboard', () => {
});
it('returns flag to display the Paste Unit button', async () => {
- const { result } = renderHook(() => useClipboard(true), { wrapper });
+ const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
axiosMock
.onPost(getClipboardUrl())
@@ -88,7 +89,7 @@ describe('useCopyToClipboard', () => {
});
it('returns flag to display the Paste XBlock button', async () => {
- const { result } = renderHook(() => useClipboard(true), { wrapper });
+ const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
axiosMock
.onPost(getClipboardUrl())
@@ -107,7 +108,7 @@ describe('useCopyToClipboard', () => {
describe('broadcast channel message handling', () => {
it('updates states correctly on receiving a broadcast message', async () => {
- const { result } = renderHook(() => useClipboard(true), { wrapper });
+ const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
expect(result.current.showPasteUnit).toBe(true);
diff --git a/src/generic/clipboard/index.js b/src/generic/clipboard/index.js
new file mode 100644
index 0000000000..2716b10c49
--- /dev/null
+++ b/src/generic/clipboard/index.js
@@ -0,0 +1,2 @@
+export { default as useCopyToClipboard } from './hooks/useCopyToClipboard';
+export { default as PasteComponent } from './paste-component';
diff --git a/src/course-unit/clipboard/paste-component/PasteComponent.scss b/src/generic/clipboard/paste-component/PasteComponent.scss
similarity index 100%
rename from src/course-unit/clipboard/paste-component/PasteComponent.scss
rename to src/generic/clipboard/paste-component/PasteComponent.scss
diff --git a/src/generic/clipboard/paste-component/components/PasteButton.jsx b/src/generic/clipboard/paste-component/components/PasteButton.jsx
new file mode 100644
index 0000000000..a13dc28c6b
--- /dev/null
+++ b/src/generic/clipboard/paste-component/components/PasteButton.jsx
@@ -0,0 +1,36 @@
+import PropsTypes from 'prop-types';
+import { useParams } from 'react-router-dom';
+import { Button } from '@openedx/paragon';
+import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
+
+const PasteButton = ({ onClick, text, className }) => {
+ const { blockId } = useParams();
+
+ const handlePasteXBlockComponent = () => {
+ onClick({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId);
+ };
+
+ return (
+
+ );
+};
+
+PasteButton.propTypes = {
+ onClick: PropsTypes.func.isRequired,
+ text: PropsTypes.string.isRequired,
+ className: PropsTypes.string,
+};
+
+PasteButton.defaultProps = {
+ className: undefined,
+};
+
+export default PasteButton;
diff --git a/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx b/src/generic/clipboard/paste-component/components/PopoverContent.jsx
similarity index 100%
rename from src/course-unit/clipboard/paste-component/components/PopoverContent.jsx
rename to src/generic/clipboard/paste-component/components/PopoverContent.jsx
diff --git a/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx
similarity index 95%
rename from src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx
rename to src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx
index 939dcfa2d5..d4e532b13c 100644
--- a/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx
+++ b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx
@@ -40,7 +40,7 @@ const WhatsInClipboard = ({
className="whats-in-clipboard-text m-0"
onKeyDown={handleKeyDown}
>
- {intl.formatMessage(messages.pasteComponentWhatsInClipboardText)}
+ {intl.formatMessage(messages.pasteButtonWhatsInClipboardText)}
);
diff --git a/src/course-unit/clipboard/paste-component/components/index.js b/src/generic/clipboard/paste-component/components/index.js
similarity index 63%
rename from src/course-unit/clipboard/paste-component/components/index.js
rename to src/generic/clipboard/paste-component/components/index.js
index 86980f4b9b..1336513b37 100644
--- a/src/course-unit/clipboard/paste-component/components/index.js
+++ b/src/generic/clipboard/paste-component/components/index.js
@@ -1,3 +1,3 @@
export { default as WhatsInClipboard } from './WhatsInClipboard';
-export { default as PasteComponentButton } from './PasteComponentButton';
+export { default as PasteButton } from './PasteButton';
export { default as PopoverContent } from './PopoverContent';
diff --git a/src/course-unit/clipboard/paste-component/constants.js b/src/generic/clipboard/paste-component/constants.js
similarity index 100%
rename from src/course-unit/clipboard/paste-component/constants.js
rename to src/generic/clipboard/paste-component/constants.js
diff --git a/src/course-unit/clipboard/paste-component/index.jsx b/src/generic/clipboard/paste-component/index.jsx
similarity index 78%
rename from src/course-unit/clipboard/paste-component/index.jsx
rename to src/generic/clipboard/paste-component/index.jsx
index ab140bf383..af4674952a 100644
--- a/src/course-unit/clipboard/paste-component/index.jsx
+++ b/src/generic/clipboard/paste-component/index.jsx
@@ -2,10 +2,12 @@ import { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
-import { PopoverContent, PasteComponentButton, WhatsInClipboard } from './components';
+import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
import { clipboardPropsTypes, OVERLAY_TRIGGERS } from './constants';
-const PasteComponent = ({ handleCreateNewCourseXBlock, clipboardData }) => {
+const PasteComponent = ({
+ onClick, clipboardData, text, className,
+}) => {
const [showPopover, togglePopover] = useState(false);
const popoverElementRef = useRef(null);
@@ -31,9 +33,7 @@ const PasteComponent = ({ handleCreateNewCourseXBlock, clipboardData }) => {
return (
<>
-
+
{
};
PasteComponent.propTypes = {
- handleCreateNewCourseXBlock: PropTypes.func.isRequired,
+ onClick: PropTypes.func.isRequired,
+ text: PropTypes.string.isRequired,
clipboardData: PropTypes.shape(clipboardPropsTypes),
+ blockType: PropTypes.string,
+ className: PropTypes.string,
};
PasteComponent.defaultProps = {
clipboardData: null,
+ blockType: null,
+ className: undefined,
};
export default PasteComponent;
diff --git a/src/generic/clipboard/paste-component/messages.js b/src/generic/clipboard/paste-component/messages.js
new file mode 100644
index 0000000000..47c229b06d
--- /dev/null
+++ b/src/generic/clipboard/paste-component/messages.js
@@ -0,0 +1,14 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ popoverContentText: {
+ id: 'course-authoring.generic.paste-component.popover.content.text',
+ defaultMessage: 'From:',
+ },
+ pasteButtonWhatsInClipboardText: {
+ id: 'course-authoring.generic.paste-component.paste-button.whats-in-clipboard.text',
+ defaultMessage: "What's in my clipboard?",
+ },
+});
+
+export default messages;
diff --git a/src/generic/data/api.js b/src/generic/data/api.js
index 6cec7b9159..83fd561ff3 100644
--- a/src/generic/data/api.js
+++ b/src/generic/data/api.js
@@ -8,6 +8,7 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href;
export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href;
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
+export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href;
/**
@@ -45,6 +46,29 @@ export async function createOrRerunCourse(courseData) {
return camelCaseObject(data);
}
+/**
+ * Retrieves user's clipboard.
+ * @returns {Promise} - A Promise that resolves clipboard data.
+ */
+export async function getClipboard() {
+ const { data } = await getAuthenticatedHttpClient()
+ .get(getClipboardUrl());
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Updates user's clipboard.
+ * @param {string} usageKey - The ID of the block.
+ * @returns {Promise} - A Promise that resolves clipboard data.
+ */
+export async function updateClipboard(usageKey) {
+ const { data } = await getAuthenticatedHttpClient()
+ .post(getClipboardUrl(), { usage_key: usageKey });
+
+ return camelCaseObject(data);
+}
+
/**
* Gets the tags count of multiple content by id separated by commas or a pattern using a '*' wildcard.
* @param {string} contentPattern
diff --git a/src/generic/data/selectors.js b/src/generic/data/selectors.js
index 461e09fe98..e111961b15 100644
--- a/src/generic/data/selectors.js
+++ b/src/generic/data/selectors.js
@@ -5,3 +5,4 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
+export const getClipboardData = (state) => state.generic.clipboardData;
diff --git a/src/generic/data/slice.js b/src/generic/data/slice.js
index a25112704e..f53ddc610e 100644
--- a/src/generic/data/slice.js
+++ b/src/generic/data/slice.js
@@ -18,6 +18,7 @@ const slice = createSlice({
redirectUrlObj: {},
postErrors: {},
},
+ clipboardData: null,
},
reducers: {
fetchOrganizations: (state, { payload }) => {
@@ -41,6 +42,9 @@ const slice = createSlice({
updatePostErrors: (state, { payload }) => {
state.createOrRerunCourse.postErrors = payload;
},
+ updateClipboardData: (state, { payload }) => {
+ state.clipboardData = payload;
+ },
},
});
@@ -52,6 +56,7 @@ export const {
updateSavingStatus,
updateCourseData,
updateRedirectUrlObj,
+ updateClipboardData,
} = slice.actions;
export const {
diff --git a/src/generic/data/thunks.js b/src/generic/data/thunks.js
index 0008a187f4..f5cc8a9557 100644
--- a/src/generic/data/thunks.js
+++ b/src/generic/data/thunks.js
@@ -1,5 +1,11 @@
+import { logError } from '@edx/frontend-platform/logging';
+
+import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants';
+import {
+ hideProcessingNotification,
+ showProcessingNotification,
+} from '../processing-notification/data/slice';
import { RequestStatus } from '../../data/constants';
-import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api';
import {
fetchOrganizations,
updatePostErrors,
@@ -7,7 +13,15 @@ import {
updateRedirectUrlObj,
updateCourseRerunData,
updateSavingStatus,
+ updateClipboardData,
} from './slice';
+import {
+ createOrRerunCourse,
+ getOrganizations,
+ getCourseRerun,
+ updateClipboard,
+ getClipboard,
+} from './api';
export function fetchOrganizationsQuery() {
return async (dispatch) => {
@@ -49,3 +63,33 @@ export function updateCreateOrRerunCourseQuery(courseData) {
}
};
}
+
+export function copyToClipboard(usageKey) {
+ const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds
+
+ return async (dispatch) => {
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
+ dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
+
+ try {
+ let clipboardData = await updateClipboard(usageKey);
+
+ while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) {
+ // eslint-disable-next-line no-await-in-loop,no-promise-executor-return
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
+ clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop
+ }
+
+ if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) {
+ dispatch(updateClipboardData(clipboardData));
+ dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
+ } else {
+ throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`);
+ }
+ } catch (error) {
+ logError('Error copying to clipboard:', error);
+ } finally {
+ dispatch(hideProcessingNotification());
+ }
+ };
+}
diff --git a/src/generic/styles.scss b/src/generic/styles.scss
index 43aa2f5164..9c74c5b8bc 100644
--- a/src/generic/styles.scss
+++ b/src/generic/styles.scss
@@ -7,6 +7,7 @@
@import "./WysiwygEditor";
@import "./course-stepper/CouseStepper";
@import "./divider/Divider";
+@import "./clipboard/paste-component/PasteComponent";
@import "./tag-count/TagCount";
@import "./modal-dropzone/ModalDropzone";
@import "./configure-modal/ConfigureModal";
From 87783785ada40f6485170ab01a66ae42a182db70 Mon Sep 17 00:00:00 2001
From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com>
Date: Wed, 10 Apr 2024 12:32:57 +0300
Subject: [PATCH 3/5] fix: [AXIMST-767] fixed new files alert (#227)
* fix: [AXIMST-718] second attempt to fix the CSRF token
* fix: [AXIMST-718] third attempt to fix the CSRF token
* fix: [AXIMST-767] fixed new files alert
* refactor: unnecessary code removed
---
src/course-unit/data/thunk.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js
index 7ad07b44ab..e94287c03c 100644
--- a/src/course-unit/data/thunk.js
+++ b/src/course-unit/data/thunk.js
@@ -162,6 +162,11 @@ export function createNewCourseXBlock(body, callback, blockId) {
}
if (body.stagedContent) {
localStorage.setItem('staticFileNotices', JSON.stringify(formattedResult.staticFileNotices));
+ dispatch(fetchStaticFileNoticesSuccess(formattedResult.staticFileNotices));
+
+ if (body.parentLocator.includes('vertical')) {
+ localStorage.removeItem('staticFileNotices');
+ }
}
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
From e5ba0f7f081f970ade6e49681be2227feb9a6ab1 Mon Sep 17 00:00:00 2001
From: ihor-romaniuk
Date: Tue, 16 Apr 2024 18:13:58 +0200
Subject: [PATCH 4/5] refactor: remove unused code and do some refactoring
---
package.json | 3 --
src/__mocks__/clipboardUnit.js | 2 +-
src/__mocks__/clipboardXBlock.js | 2 +-
src/constants.js | 2 +-
src/course-unit/CourseUnit.test.jsx | 39 ++++++++-----------
.../__mocks__/clipboardResponse.js | 2 +-
.../clipboard/paste-notification/messages.js | 14 +++++++
src/course-unit/constants.js | 16 --------
src/course-unit/data/slice.js | 2 +-
.../clipboard/hooks/useCopyToClipboard.js | 4 +-
.../components/WhatsInClipboard.jsx | 2 +-
.../clipboard/paste-component/constants.js | 3 +-
.../clipboard/paste-component/index.jsx | 3 +-
13 files changed, 41 insertions(+), 53 deletions(-)
diff --git a/package.json b/package.json
index 31c7daf88f..3d91b2cfdc 100644
--- a/package.json
+++ b/package.json
@@ -120,8 +120,5 @@
},
"peerDependencies": {
"decode-uri-component": ">=0.2.2"
- },
- "overrides": {
- "react-intl": "^6.4.0"
}
}
diff --git a/src/__mocks__/clipboardUnit.js b/src/__mocks__/clipboardUnit.js
index d181c94ac6..fb20bde413 100644
--- a/src/__mocks__/clipboardUnit.js
+++ b/src/__mocks__/clipboardUnit.js
@@ -1,4 +1,4 @@
-module.exports = {
+export default {
content: {
id: 67,
userId: 3,
diff --git a/src/__mocks__/clipboardXBlock.js b/src/__mocks__/clipboardXBlock.js
index 621044e494..ecaf0b50b1 100644
--- a/src/__mocks__/clipboardXBlock.js
+++ b/src/__mocks__/clipboardXBlock.js
@@ -1,4 +1,4 @@
-module.exports = {
+export default {
content: {
id: 69,
userId: 3,
diff --git a/src/constants.js b/src/constants.js
index f718f549f3..87fb9d9cb8 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -66,4 +66,4 @@ export const CLIPBOARD_STATUS = {
error: 'error',
};
-export const NOT_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course'];
+export const STRUCTURAL_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course'];
diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx
index 31d2312130..4393b50a97 100644
--- a/src/course-unit/CourseUnit.test.jsx
+++ b/src/course-unit/CourseUnit.test.jsx
@@ -53,7 +53,6 @@ import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
import messages from './messages';
-import { copyToClipboard } from '../generic/data/thunks';
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
let axiosMock;
@@ -1174,8 +1173,16 @@ describe('', () => {
user_clipboard: clipboardXBlock,
});
+ axiosMock
+ .onGet(getCourseUnitApiUrl(courseId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ enable_copy_paste_units: true,
+ });
+
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
- await executeThunk(copyToClipboard(blockId), store.dispatch);
+
expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument();
});
@@ -1191,10 +1198,6 @@ describe('', () => {
enable_copy_paste_units: true,
});
- await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
-
- userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
-
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -1202,9 +1205,10 @@ describe('', () => {
user_clipboard: clipboardUnit,
});
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
- await executeThunk(copyToClipboard(blockId), store.dispatch);
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
let units = null;
@@ -1252,10 +1256,6 @@ describe('', () => {
enable_copy_paste_units: true,
});
- await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
-
- userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
-
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -1263,9 +1263,10 @@ describe('', () => {
user_clipboard: clipboardUnit,
});
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
- await executeThunk(copyToClipboard(blockId), store.dispatch);
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
@@ -1316,10 +1317,6 @@ describe('', () => {
enable_copy_paste_units: true,
});
- await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
-
- userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
-
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -1327,9 +1324,10 @@ describe('', () => {
user_clipboard: clipboardUnit,
});
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
- await executeThunk(copyToClipboard(blockId), store.dispatch);
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
@@ -1380,10 +1378,6 @@ describe('', () => {
enable_copy_paste_units: true,
});
- await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
-
- userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
-
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -1391,9 +1385,10 @@ describe('', () => {
user_clipboard: clipboardUnit,
});
+ await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
- await executeThunk(copyToClipboard(blockId), store.dispatch);
+ userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
diff --git a/src/course-unit/__mocks__/clipboardResponse.js b/src/course-unit/__mocks__/clipboardResponse.js
index 30a4248c1b..1d8a5a64d6 100644
--- a/src/course-unit/__mocks__/clipboardResponse.js
+++ b/src/course-unit/__mocks__/clipboardResponse.js
@@ -1,4 +1,4 @@
-module.exports = {
+export default {
locator: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
courseKey: 'course-v1:edX+L153+3T2023',
staticFileNotices: {
diff --git a/src/course-unit/clipboard/paste-notification/messages.js b/src/course-unit/clipboard/paste-notification/messages.js
index 2786256a87..81f37e4d5f 100644
--- a/src/course-unit/clipboard/paste-notification/messages.js
+++ b/src/course-unit/clipboard/paste-notification/messages.js
@@ -4,34 +4,48 @@ const messages = defineMessages({
hasConflictingErrorsTitle: {
id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.title',
defaultMessage: 'Files need to be updated manually.',
+ description: 'Title for a notification indicating that files need manual updates '
+ + 'due to a conflict in the clipboard.',
},
hasConflictingErrorsDescription: {
id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.description',
defaultMessage: 'The following files must be updated manually for components to work as intended:',
+ description: 'Description for the notification indicating which files need manual '
+ + 'updates due to a clipboard conflict.',
},
hasConflictingErrorsButtonText: {
id: 'course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text',
defaultMessage: 'Upload files',
+ description: 'Button text prompting users to upload files to resolve a clipboard conflict.',
},
hasErrorsTitle: {
id: 'course-authoring.course-unit.paste-notification.has-errors.title',
defaultMessage: 'Some errors occurred',
+ description: 'Title for a notification indicating that some errors occurred, likely '
+ + 'related to file conflicts.',
},
hasErrorsDescription: {
id: 'course-authoring.course-unit.paste-notification.has-errors.description',
defaultMessage: 'The following required files could not be added to the course:',
+ description: 'Description for the notification indicating which required files '
+ + 'couldn\'t be added to the course due to errors.',
},
hasNewFilesTitle: {
id: 'course-authoring.course-unit.paste-notification.has-new-files.title',
defaultMessage: 'New file(s) added to Files & Uploads.',
+ description: 'Title for a notification indicating that new files have been added to '
+ + 'the Files & Uploads section.',
},
hasNewFilesDescription: {
id: 'course-authoring.course-unit.paste-notification.has-new-files.description',
defaultMessage: 'The following required files were imported to this course:',
+ description: 'Description for the notification indicating which required files '
+ + 'were imported to the course.',
},
hasNewFilesButtonText: {
id: 'course-authoring.course-unit.paste-notification.has-new-files.button.text',
defaultMessage: 'View files',
+ description: 'Button text prompting users to view new files imported to the course.',
},
});
diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js
index 6ca687d01f..b7e7bf5c6b 100644
--- a/src/course-unit/constants.js
+++ b/src/course-unit/constants.js
@@ -18,22 +18,6 @@ import addComponentMessages from './add-component/messages';
export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock'];
-export const NOT_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course'];
-
-export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
-
-/**
- * Enum for clipboard status.
- * @readonly
- * @enum {string}
- */
-export const CLIPBOARD_STATUS = {
- loading: 'loading',
- ready: 'ready',
- expired: 'expired',
- error: 'error',
-};
-
export const COMPONENT_TYPES = {
advanced: 'advanced',
discussion: 'discussion',
diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js
index 87b60094a4..02edc09757 100644
--- a/src/course-unit/data/slice.js
+++ b/src/course-unit/data/slice.js
@@ -17,7 +17,7 @@ const slice = createSlice({
},
unit: {},
courseSectionVertical: {},
- courseVerticalChildren: {},
+ courseVerticalChildren: { children: [], isPublished: true },
staticFileNotices: {},
},
reducers: {
diff --git a/src/generic/clipboard/hooks/useCopyToClipboard.js b/src/generic/clipboard/hooks/useCopyToClipboard.js
index 862788e0bb..86303fab95 100644
--- a/src/generic/clipboard/hooks/useCopyToClipboard.js
+++ b/src/generic/clipboard/hooks/useCopyToClipboard.js
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
-import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
+import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
import { getClipboardData } from '../../data/selectors';
/**
@@ -23,7 +23,7 @@ const useCopyToClipboard = (canEdit = true) => {
// Function to refresh the paste button's visibility
const refreshPasteButton = (data) => {
const isPasteable = canEdit && data?.content && data.content.status !== CLIPBOARD_STATUS.expired;
- const isPasteableXBlock = isPasteable && !NOT_XBLOCK_TYPES.includes(data.content.blockType);
+ const isPasteableXBlock = isPasteable && !STRUCTURAL_XBLOCK_TYPES.includes(data.content.blockType);
const isPasteableUnit = isPasteable && data.content.blockType === 'vertical';
setShowPasteXBlock(!!isPasteableXBlock);
diff --git a/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx
index d4e532b13c..aca6d3f0cc 100644
--- a/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx
+++ b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx
@@ -14,7 +14,7 @@ const WhatsInClipboard = ({
const handleKeyDown = ({ key }) => {
if (key === 'Tab') {
- popoverElementRef.current.focus();
+ popoverElementRef.current?.focus();
handlePopoverToggle(true);
}
};
diff --git a/src/generic/clipboard/paste-component/constants.js b/src/generic/clipboard/paste-component/constants.js
index 454f332c84..1dc7a1c526 100644
--- a/src/generic/clipboard/paste-component/constants.js
+++ b/src/generic/clipboard/paste-component/constants.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
+/* eslint-disable import/prefer-default-export */
export const clipboardPropsTypes = {
sourceEditUrl: PropTypes.string.isRequired,
content: PropTypes.shape({
@@ -8,5 +9,3 @@ export const clipboardPropsTypes = {
}).isRequired,
sourceContextTitle: PropTypes.string.isRequired,
};
-
-export const OVERLAY_TRIGGERS = ['hover', 'focus'];
diff --git a/src/generic/clipboard/paste-component/index.jsx b/src/generic/clipboard/paste-component/index.jsx
index af4674952a..a6602bb079 100644
--- a/src/generic/clipboard/paste-component/index.jsx
+++ b/src/generic/clipboard/paste-component/index.jsx
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
-import { clipboardPropsTypes, OVERLAY_TRIGGERS } from './constants';
+import { clipboardPropsTypes } from './constants';
const PasteComponent = ({
onClick, clipboardData, text, className,
@@ -36,7 +36,6 @@ const PasteComponent = ({
Date: Wed, 24 Apr 2024 19:48:29 +0200
Subject: [PATCH 5/5] fix: add message descriptions
---
src/generic/clipboard/paste-component/messages.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/generic/clipboard/paste-component/messages.js b/src/generic/clipboard/paste-component/messages.js
index 47c229b06d..77fbcba2b8 100644
--- a/src/generic/clipboard/paste-component/messages.js
+++ b/src/generic/clipboard/paste-component/messages.js
@@ -4,10 +4,12 @@ const messages = defineMessages({
popoverContentText: {
id: 'course-authoring.generic.paste-component.popover.content.text',
defaultMessage: 'From:',
+ description: 'The popover content label before the source course name of the copied content.',
},
pasteButtonWhatsInClipboardText: {
id: 'course-authoring.generic.paste-component.paste-button.whats-in-clipboard.text',
defaultMessage: "What's in my clipboard?",
+ description: 'The popover trigger button text of the info about copied content.',
},
});