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} A Promise that resolves to the created XBlock data. + * @param {string} options.parentLocator - The parent locator. + * @param {string} [options.displayName] - The display name. + * @param {string} [options.boilerplate] - The boilerplate. + * @param {string} [options.stagedContent] - The staged content. */ export async function createCourseXblock({ - type, category, parentLocator, displayName, boilerplate, + type, category, parentLocator, displayName, boilerplate, stagedContent, }) { const body = { type, @@ -74,6 +75,7 @@ export async function createCourseXblock({ category: category || type, parent_locator: parentLocator, display_name: displayName, + staged_content: stagedContent, }; const { data } = await getAuthenticatedHttpClient() @@ -82,6 +84,29 @@ 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 16619548ff..19d1a2c1b2 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -1,4 +1,7 @@ export const getCourseUnitData = (state) => state.courseUnit.unit; +export const getCanEdit = (state) => state.courseUnit.canEdit; +export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices; +export const getCourseUnit = (state) => state.courseUnit; export const getSavingStatus = (state) => state.courseUnit.savingStatus; export const getLoadingStatus = (state) => state.courseUnit.loadingStatus; export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus; @@ -7,3 +10,4 @@ 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; diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 436a957b2b..b1d6490c87 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -9,6 +9,7 @@ const slice = createSlice({ savingStatus: '', isQueryPending: false, isTitleEditFormOpen: false, + canEdit: true, loadingStatus: { fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS, courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS, @@ -17,6 +18,8 @@ const slice = createSlice({ unit: {}, courseSectionVertical: {}, courseVerticalChildren: {}, + clipboardData: null, + staticFileNotices: {}, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { @@ -95,6 +98,12 @@ const slice = createSlice({ }), }; }, + updateClipboardData: (state, { payload }) => { + state.clipboardData = payload; + }, + fetchStaticFileNoticesSuccess: (state, { payload }) => { + state.staticFileNotices = payload; + }, }, }); @@ -115,6 +124,8 @@ export const { updateCourseVerticalChildrenLoadingStatus, deleteXBlock, duplicateXBlock, + updateClipboardData, + fetchStaticFileNoticesSuccess, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 6d63531881..ae5ea704b7 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -1,4 +1,5 @@ import { camelCaseObject } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; import { hideProcessingNotification, @@ -7,12 +8,15 @@ import { import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { updateModel, updateModels } from '../../generic/model-store'; +import { CLIPBOARD_STATUS } from '../constants'; import { getCourseUnitData, editUnitDisplayName, getCourseSectionVerticalData, createCourseXblock, getCourseVerticalChildren, + updateClipboard, + getClipboard, handleCourseUnitVisibilityAndData, deleteUnitItem, duplicateUnitItem, @@ -29,9 +33,11 @@ import { updateLoadingCourseXblockStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, + updateClipboardData, updateQueryPendingStatus, deleteXBlock, duplicateXBlock, + fetchStaticFileNoticesSuccess, } from './slice'; import { getNotificationMessage } from './utils'; @@ -68,6 +74,9 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) { modelType: 'units', models: courseSectionVerticalData.units, })); + dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices')))); + localStorage.removeItem('staticFileNotices'); + dispatch(updateClipboardData(courseSectionVerticalData.userClipboard)); dispatch(fetchSequenceSuccess({ sequenceId })); return true; } catch (error) { @@ -139,9 +148,14 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAc export function createNewCourseXBlock(body, callback, blockId) { return async (dispatch) => { dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.adding)); dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + if (body.stagedContent) { + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); + } else { + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.adding)); + } + try { await createCourseXblock(body).then(async (result) => { if (result) { @@ -150,6 +164,9 @@ export function createNewCourseXBlock(body, callback, blockId) { const courseSectionVerticalData = await getCourseSectionVerticalData(formattedResult.locator); dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); } + if (body.stagedContent) { + localStorage.setItem('staticFileNotices', JSON.stringify(formattedResult.staticFileNotices)); + } const courseVerticalChildrenData = await getCourseVerticalChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); @@ -162,8 +179,6 @@ export function createNewCourseXBlock(body, callback, blockId) { const courseUnit = await getCourseUnitData(currentBlockId); dispatch(fetchCourseItemSuccess(courseUnit)); } - const courseUnit = await getCourseUnitData(blockId); - dispatch(fetchCourseItemSuccess(courseUnit)); }); } catch (error) { dispatch(hideProcessingNotification()); @@ -195,6 +210,8 @@ export function deleteUnitItemQuery(itemId, xblockId) { try { await deleteUnitItem(xblockId); dispatch(deleteXBlock(xblockId)); + const { userClipboard } = await getCourseSectionVerticalData(itemId); + dispatch(updateClipboardData(userClipboard)); const courseUnit = await getCourseUnitData(itemId); dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(hideProcessingNotification()); @@ -228,3 +245,35 @@ 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 a2b0726e71..97675b58e8 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -20,10 +20,14 @@ import { getLoadingStatus, getSavingStatus, getSequenceStatus, + getStaticFileNotices, + getCanEdit, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; import { PUBLISH_TYPES } from './constants'; +import { useCopyToClipboard } from './clipboard'; + // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -36,10 +40,14 @@ export const useCourseUnit = ({ courseId, blockId }) => { const sequenceStatus = useSelector(getSequenceStatus); const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical); const courseVerticalChildren = useSelector(getCourseVerticalChildren); + const staticFileNotices = useSelector(getStaticFileNotices); const navigate = useNavigate(); const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen); const isQueryPending = useSelector(state => state.courseUnit.isQueryPending); + const canEdit = useSelector(getCanEdit); const { currentlyVisibleToStudents } = courseUnit; + const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); + const { canPasteComponent } = courseVerticalChildren; const unitTitle = courseUnit.metadata?.displayName || ''; const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id; @@ -117,11 +125,15 @@ export const useCourseUnit = ({ courseId, blockId }) => { savingStatus, isQueryPending, isErrorAlert, + staticFileNotices, currentlyVisibleToStudents, isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS || loadingStatus.courseSectionVerticalLoadingStatus === RequestStatus.IN_PROGRESS, isTitleEditFormOpen, isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, + sharedClipboardData, + showPasteXBlock, + showPasteUnit, handleInternetConnectionFailed, unitXBlockActions, headerNavigationsActions, @@ -130,5 +142,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleCreateNewCourseXBlock, handleConfigureSubmit, courseVerticalChildren, + canPasteComponent, }; }; diff --git a/src/course-unit/sidebar/Sidebar.scss b/src/course-unit/sidebar/Sidebar.scss index 0fbae7eb6a..f60bfe0879 100644 --- a/src/course-unit/sidebar/Sidebar.scss +++ b/src/course-unit/sidebar/Sidebar.scss @@ -50,6 +50,14 @@ line-height: $headings-line-height; } } + + .course-unit-sidebar-footer__divider { + margin: map-get($spacers, 3\.5) map-get($spacers, 0) map-get($spacers, 3\.5); + } + + .course-unit-sidebar-footer__discard-changes__btn + .course-unit-sidebar-footer__divider { + margin: map-get($spacers, 2) map-get($spacers, 0) map-get($spacers, 3\.5); + } } .course-unit-sidebar-date { diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx index ac0a63287d..a3e7e03afa 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx @@ -1,18 +1,23 @@ import PropTypes from 'prop-types'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Button } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { getCourseUnitData } from '../../../data/selectors'; +import { Divider } from '../../../../generic/divider'; +import { getCanEdit, getCourseUnitData } from '../../../data/selectors'; +import { copyToClipboard } from '../../../data/thunk'; import messages from '../../messages'; const ActionButtons = ({ openDiscardModal, handlePublishing }) => { + const dispatch = useDispatch(); const intl = useIntl(); const { + id, published, hasChanges, enableCopyPasteUnits, } = useSelector(getCourseUnitData); + const canEdit = useSelector(getCanEdit); return ( <> @@ -22,14 +27,26 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { )} {(published && hasChanges) && ( - )} - {enableCopyPasteUnits && ( - + {enableCopyPasteUnits && canEdit && ( + <> + + + )} ); diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx new file mode 100644 index 0000000000..a91aba196b --- /dev/null +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -0,0 +1,77 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +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 messages from '../../messages'; +import ActionButtons from './ActionButtons'; + +jest.mock('../../../data/thunk', () => ({ + ...jest.requireActual('../../../data/thunk'), + copyToClipboard: jest.fn().mockImplementation(() => () => {}), +})); + +let store; +let axiosMock; +const courseId = '123'; + +const renderComponent = (props = {}) => render( + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { ...courseUnitIndexMock, enable_copy_paste_units: true }); + axiosMock + .onPost(getClipboardUrl()) + .reply(200, clipboardUnit); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); + + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + }); + + it('render ActionButtons component with Copy to clipboard', () => { + const { getByRole } = renderComponent(); + + const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage }); + expect(copyXBlockBtn).toBeInTheDocument(); + }); + + it('click on the Copy to clipboard button updates clipboardData', async () => { + const { getByRole } = renderComponent(); + + const copyXBlockBtn = getByRole('button', { name: messages.actionButtonCopyUnitTitle.defaultMessage }); + + userEvent.click(copyXBlockBtn); + + expect(copyToClipboard).toHaveBeenCalledWith(courseUnitIndexMock.id); + jest.resetAllMocks(); + }); +}); diff --git a/src/generic/divider/Divider.jsx b/src/generic/divider/Divider.jsx new file mode 100644 index 0000000000..6b75eff3df --- /dev/null +++ b/src/generic/divider/Divider.jsx @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +const Divider = ({ className, ...props }) => ( +
+); + +Divider.propTypes = { + className: PropTypes.string, +}; + +Divider.defaultProps = { + className: undefined, +}; + +export default Divider; diff --git a/src/generic/divider/Divider.scss b/src/generic/divider/Divider.scss new file mode 100644 index 0000000000..b78206689d --- /dev/null +++ b/src/generic/divider/Divider.scss @@ -0,0 +1,5 @@ +.divider { + border-top: $border-width solid $light-400; + height: 0; + margin: $spacer map-get($spacers, 0); +} diff --git a/src/generic/divider/index.jsx b/src/generic/divider/index.jsx new file mode 100644 index 0000000000..ca4fc16364 --- /dev/null +++ b/src/generic/divider/index.jsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as Divider } from './Divider'; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 0ef6a6202e..43aa2f5164 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -6,6 +6,7 @@ @import "./create-or-rerun-course/CreateOrRerunCourseForm"; @import "./WysiwygEditor"; @import "./course-stepper/CouseStepper"; +@import "./divider/Divider"; @import "./tag-count/TagCount"; @import "./modal-dropzone/ModalDropzone"; @import "./configure-modal/ConfigureModal"; From 2a898060e20ad8f629e74b4878ab2908c6a530aa Mon Sep 17 00:00:00 2001 From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com> Date: Wed, 20 Mar 2024 18:48:18 +0200 Subject: [PATCH 2/5] refactor: [AXIMST-658] Course unit - Copy/paste refactoring (#204) * refactor: copy paste functional refactoring * refactor: refactoring paste-button * refactor: tests refactoring * refactor: updated translations * refactor: refactoring after review * refactor: renamed status selector name --- package-lock.json | 73 ----------- package.json | 1 - .../__mocks__/clipboardUnit.js | 0 .../__mocks__/clipboardXBlock.js | 0 src/__mocks__/index.js | 2 + src/constants.js | 11 ++ src/course-outline/CourseOutline.scss | 1 - src/course-outline/CourseOutline.test.jsx | 48 +++----- src/course-outline/data/api.js | 14 --- src/course-outline/data/selectors.js | 1 - src/course-outline/data/slice.js | 11 -- src/course-outline/data/thunk.js | 28 +---- src/course-outline/hooks.jsx | 16 +-- .../paste-button/PasteButton.jsx | 115 ------------------ .../paste-button/PasteButton.scss | 20 --- src/course-outline/paste-button/messages.js | 14 --- .../subsection-card/SubsectionCard.jsx | 13 +- .../subsection-card/SubsectionCard.test.jsx | 8 +- src/course-unit/CourseUnit.jsx | 6 +- src/course-unit/CourseUnit.scss | 1 - src/course-unit/CourseUnit.test.jsx | 26 ++-- src/course-unit/__mocks__/index.js | 2 - src/course-unit/clipboard/index.js | 3 +- .../components/PasteComponentButton.jsx | 33 ----- .../clipboard/paste-component/messages.js | 18 --- .../course-xblock/CourseXBlock.jsx | 2 +- src/course-unit/data/api.js | 25 ---- src/course-unit/data/selectors.js | 11 +- src/course-unit/data/slice.js | 5 - src/course-unit/data/thunk.js | 38 +----- src/course-unit/hooks.jsx | 2 +- src/course-unit/messages.js | 4 + .../sidebar-footer/ActionButtons.jsx | 2 +- .../sidebar-footer/ActionButtons.test.jsx | 13 +- src/generic/broadcast-channel/hooks.js | 46 ------- .../clipboard/hooks/useCopyToClipboard.js} | 15 ++- .../hooks/useCopyToClipboard.test.jsx} | 17 +-- src/generic/clipboard/index.js | 2 + .../paste-component/PasteComponent.scss | 0 .../components/PasteButton.jsx | 36 ++++++ .../components/PopoverContent.jsx | 0 .../components/WhatsInClipboard.jsx | 2 +- .../paste-component/components/index.js | 2 +- .../clipboard/paste-component/constants.js | 0 .../clipboard/paste-component/index.jsx | 17 ++- .../clipboard/paste-component/messages.js | 14 +++ src/generic/data/api.js | 24 ++++ src/generic/data/selectors.js | 1 + src/generic/data/slice.js | 5 + src/generic/data/thunks.js | 46 ++++++- src/generic/styles.scss | 1 + 51 files changed, 264 insertions(+), 531 deletions(-) rename src/{course-unit => }/__mocks__/clipboardUnit.js (100%) rename src/{course-unit => }/__mocks__/clipboardXBlock.js (100%) create mode 100644 src/__mocks__/index.js delete mode 100644 src/course-outline/paste-button/PasteButton.jsx delete mode 100644 src/course-outline/paste-button/PasteButton.scss delete mode 100644 src/course-outline/paste-button/messages.js delete mode 100644 src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx delete mode 100644 src/course-unit/clipboard/paste-component/messages.js delete mode 100644 src/generic/broadcast-channel/hooks.js rename src/{course-unit/clipboard/hooks/useClipboard.jsx => generic/clipboard/hooks/useCopyToClipboard.js} (74%) rename src/{course-unit/clipboard/hooks/useClipboard.test.jsx => generic/clipboard/hooks/useCopyToClipboard.test.jsx} (85%) create mode 100644 src/generic/clipboard/index.js rename src/{course-unit => generic}/clipboard/paste-component/PasteComponent.scss (100%) create mode 100644 src/generic/clipboard/paste-component/components/PasteButton.jsx rename src/{course-unit => generic}/clipboard/paste-component/components/PopoverContent.jsx (100%) rename src/{course-unit => generic}/clipboard/paste-component/components/WhatsInClipboard.jsx (95%) rename src/{course-unit => generic}/clipboard/paste-component/components/index.js (63%) rename src/{course-unit => generic}/clipboard/paste-component/constants.js (100%) rename src/{course-unit => generic}/clipboard/paste-component/index.jsx (78%) create mode 100644 src/generic/clipboard/paste-component/messages.js diff --git a/package-lock.json b/package-lock.json index e46b24c7a3..2cb790186e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "@openedx/paragon": "^22.2.1", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", - "broadcast-channel": "^7.0.0", "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", @@ -7794,36 +7793,6 @@ "node": ">=8" } }, - "node_modules/broadcast-channel": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.0.0.tgz", - "integrity": "sha512-a2tW0Ia1pajcPBOGUF2jXlDnvE9d5/dg6BG9h60OmRUcZVr/veUrU8vEQFwwQIhwG3KVzYwSk3v2nRRGFgQDXQ==", - "dependencies": { - "@babel/runtime": "7.23.4", - "oblivious-set": "1.4.0", - "p-queue": "6.6.2", - "unload": "2.4.1" - }, - "funding": { - "url": "https://github.com/sponsors/pubkey" - } - }, - "node_modules/broadcast-channel/node_modules/@babel/runtime": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz", - "integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/broadcast-channel/node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -16362,14 +16331,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/oblivious-set": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.4.0.tgz", - "integrity": "sha512-szyd0ou0T8nsAqHtprRcP3WidfsN1TnAR5yWXf2mFCEr5ek3LEOkT6EZ/92Xfs74HIdyhG5WkGxIssMU0jBaeg==", - "engines": { - "node": ">=16" - } - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -16541,21 +16502,6 @@ "node": ">=6" } }, - "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -16568,17 +16514,6 @@ "node": ">=8" } }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dependencies": { - "p-finally": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -21992,14 +21927,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unload": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz", - "integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==", - "funding": { - "url": "https://github.com/sponsors/pubkey" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 3b923bc997..31c7daf88f 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "@openedx/paragon": "^22.2.1", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", - "broadcast-channel": "^7.0.0", "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", diff --git a/src/course-unit/__mocks__/clipboardUnit.js b/src/__mocks__/clipboardUnit.js similarity index 100% rename from src/course-unit/__mocks__/clipboardUnit.js rename to src/__mocks__/clipboardUnit.js diff --git a/src/course-unit/__mocks__/clipboardXBlock.js b/src/__mocks__/clipboardXBlock.js similarity index 100% rename from src/course-unit/__mocks__/clipboardXBlock.js rename to src/__mocks__/clipboardXBlock.js diff --git a/src/__mocks__/index.js b/src/__mocks__/index.js new file mode 100644 index 0000000000..b3b5984d3e --- /dev/null +++ b/src/__mocks__/index.js @@ -0,0 +1,2 @@ +export { default as clipboardUnit } from './clipboardUnit'; +export { default as clipboardXBlock } from './clipboardXBlock'; diff --git a/src/constants.js b/src/constants.js index 47c441b8a2..f718f549f3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -56,3 +56,14 @@ export const COURSE_BLOCK_NAMES = ({ vertical: { id: 'vertical', name: 'Unit' }, component: { id: 'component', name: 'Component' }, }); + +export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel'; + +export const CLIPBOARD_STATUS = { + loading: 'loading', + ready: 'ready', + expired: 'expired', + error: 'error', +}; + +export const NOT_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course']; diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 4d52c39873..19bdb37c40 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -9,7 +9,6 @@ @import "./publish-modal/PublishModal"; @import "./drag-helper/SortableItem"; @import "./xblock-status/XBlockStatus"; -@import "./paste-button/PasteButton"; div.row:has(> div > div.highlight) { animation: 5s glow; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 51e50ee8d1..ce85ec1b9a 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -3,7 +3,7 @@ import { } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; -import { initializeMockApp } from '@edx/frontend-platform'; +import { getConfig, initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -19,7 +19,6 @@ import { getCourseBlockApiUrl, getCourseItemApiUrl, getXBlockBaseApiUrl, - getClipboardUrl, } from './data/api'; import { RequestStatus } from '../data/constants'; import { @@ -37,16 +36,19 @@ import { courseSectionMock, courseSubsectionMock, } from './__mocks__'; +import { clipboardUnit } from '../__mocks__'; import { executeThunk } from '../utils'; import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants'; import CourseOutline from './CourseOutline'; import configureModalMessages from '../generic/configure-modal/messages'; +import pasteButtonMessages from '../generic/clipboard/paste-component/messages'; +import messages from './messages'; +import { getClipboardUrl } from '../generic/data/api'; import headerMessages from './header-navigations/messages'; import cardHeaderMessages from './card-header/messages'; import enableHighlightsModalMessages from './enable-highlights-modal/messages'; import statusBarMessages from './status-bar/messages'; -import pasteButtonMessages from './paste-button/messages'; import subsectionMessages from './subsection-card/messages'; import pageAlertMessages from './page-alerts/messages'; import { @@ -55,7 +57,6 @@ import { moveSubsection, moveUnit, } from './drag-helper/utils'; -import messages from './messages'; let axiosMock; let store; @@ -64,6 +65,13 @@ const courseId = '123'; window.HTMLElement.prototype.scrollIntoView = jest.fn(); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: () => ({ @@ -2080,7 +2088,7 @@ describe('', () => { }); it('check whether unit copy & paste option works correctly', async () => { - const { findAllByTestId, findAllByRole } = render(); + const { findAllByTestId, queryByTestId, findAllByRole } = render(); // get first section -> first subsection -> first unit element const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); @@ -2091,27 +2099,11 @@ describe('', () => { const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); - const expectedClipboardContent = { - content: { - blockType: 'vertical', - blockTypeDisplay: 'Unit', - created: '2024-01-29T07:58:36.844249Z', - displayName: unit.displayName, - id: 15, - olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx', - purpose: 'clipboard', - status: 'ready', - userId: 3, - }, - sourceUsageKey: unit.id, - sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName, - sourceEditUrl: unit.studioUrl, - }; // mock api call axiosMock .onPost(getClipboardUrl(), { usage_key: unit.id, - }).reply(200, expectedClipboardContent); + }).reply(200, clipboardUnit); // check that initialUserClipboard state is empty const { initialUserClipboard } = store.getState().courseOutline; expect(initialUserClipboard).toBeUndefined(); @@ -2125,19 +2117,19 @@ describe('', () => { await act(async () => fireEvent.click(copyButton)); // check that initialUserClipboard state is updated - expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent); + expect(store.getState().generic.clipboardData).toEqual(clipboardUnit); [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); // find clipboard content label const clipboardLabel = await within(subsectionElement).findByText( - pasteButtonMessages.clipboardContentLabel.defaultMessage, + pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage, ); await act(async () => fireEvent.mouseOver(clipboardLabel)); - // find clipboard content popup link - expect( - subsectionElement.querySelector('#vertical-paste-button-overlay'), - ).toHaveAttribute('href', unit.studioUrl); + // find clipboard content popover link + const popoverContent = queryByTestId('popover-content'); + expect(popoverContent.tagName).toBe('A'); + expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`); // check paste button functionality // mock api call diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 6b12cf62d1..7d6634b550 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -434,20 +434,6 @@ export async function setVideoSharingOption(courseId, videoSharingOption) { return data; } -/** - * Copy block to clipboard - * @param {string} usageKey - * @returns {Promise} -*/ -export async function copyBlockToClipboard(usageKey) { - const { data } = await getAuthenticatedHttpClient() - .post(getClipboardUrl(), { - usage_key: usageKey, - }); - - return camelCaseObject(data); -} - /** * Paste block to clipboard * @param {string} parentLocator diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index e0a0f3843f..a57288e538 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -8,6 +8,5 @@ export const getCurrentSection = (state) => state.courseOutline.currentSection; export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection; export const getCourseActions = (state) => state.courseOutline.actions; export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; -export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard; export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 5f5369cb00..4214ddbd7c 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -38,12 +38,6 @@ const slice = createSlice({ childAddable: true, duplicable: true, }, - initialUserClipboard: { - content: {}, - sourceUsageKey: null, - sourceContexttitle: null, - sourceEditUrl: null, - }, enableProctoredExams: false, pasteFileNotices: {}, }, @@ -52,7 +46,6 @@ const slice = createSlice({ state.outlineIndexData = payload; state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; - state.initialUserClipboard = payload.initialUserClipboard; state.enableProctoredExams = payload.courseStructure?.enableProctoredExams; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { @@ -79,9 +72,6 @@ const slice = createSlice({ ...payload, }; }, - updateClipboardContent: (state, { payload }) => { - state.initialUserClipboard = payload; - }, updateCourseActions: (state, { payload }) => { state.actions = { ...state.actions, @@ -210,7 +200,6 @@ export const { reorderSectionList, reorderSubsectionList, reorderUnitList, - updateClipboardContent, setPasteFileNotices, removePasteFileNotices, } = slice.actions; diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 932ba5c4de..4819498e8c 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -1,4 +1,5 @@ import { RequestStatus } from '../../data/constants'; +import { updateClipboardData } from '../../generic/data/slice'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { COURSE_BLOCK_NAMES } from '../constants'; import { @@ -28,7 +29,6 @@ import { setSectionOrderList, setVideoSharingOption, setCourseItemOrderList, - copyBlockToClipboard, pasteBlock, dismissNotification, } from './api'; @@ -50,7 +50,6 @@ import { deleteUnit, duplicateSection, reorderSectionList, - updateClipboardContent, setPasteFileNotices, } from './slice'; @@ -70,6 +69,7 @@ export function fetchCourseOutlineIndexQuery(courseId) { }, } = outlineIndex; dispatch(fetchOutlineIndexSuccess(outlineIndex)); + dispatch(updateClipboardData(outlineIndex.initialUserClipboard)); dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging, @@ -607,30 +607,6 @@ export function setUnitOrderListQuery( }; } -export function setClipboardContent(usageKey, broadcastClipboard) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); - - try { - await copyBlockToClipboard(usageKey).then(async (result) => { - const status = result?.content?.status; - if (status === 'ready') { - dispatch(updateClipboardContent(result)); - broadcastClipboard(result); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(hideProcessingNotification()); - } else { - throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); - } - }); - } catch (error) { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - export function pasteClipboardContent(parentLocator, sectionId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 83290de0ac..14a814065b 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -4,14 +4,14 @@ import { useNavigate } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; +import { copyToClipboard } from '../generic/data/thunks'; +import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors'; import { RequestStatus } from '../data/constants'; import { COURSE_BLOCK_NAMES } from './constants'; -import { useBroadcastChannel } from '../generic/broadcast-channel/hooks'; import { setCurrentItem, setCurrentSection, updateSavingStatus, - updateClipboardContent, } from './data/slice'; import { getLoadingStatus, @@ -50,7 +50,6 @@ import { setVideoSharingOptionQuery, setSubsectionOrderListQuery, setUnitOrderListQuery, - setClipboardContent, pasteClipboardContent, dismissNotificationQuery, } from './data/thunk'; @@ -81,6 +80,7 @@ const useCourseOutline = ({ courseId }) => { const currentSection = useSelector(getCurrentSection); const currentSubsection = useSelector(getCurrentSubsection); const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag); + const genericSavingStatus = useSelector(getGenericSavingStatus); const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false); const [isSectionsExpanded, setSectionsExpanded] = useState(true); @@ -91,12 +91,11 @@ const useCourseOutline = ({ courseId }) => { const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); - const clipboardBroadcastChannel = useBroadcastChannel('studio_clipboard_channel', (message) => { - dispatch(updateClipboardContent(message)); - }); + + const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; const handleCopyToClipboardClick = (usageKey) => { - dispatch(setClipboardContent(usageKey, clipboardBroadcastChannel.postMessage)); + dispatch(copyToClipboard(usageKey)); }; const handlePasteClipboardClick = (parentLocator, sectionId) => { @@ -328,7 +327,7 @@ const useCourseOutline = ({ courseId }) => { isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal, - isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, + isInternetConnectionAlertFailed: isSavingStatusFailed, handleInternetConnectionFailed, handleOpenHighlightsModal, isHighlightsModalOpen, @@ -358,6 +357,7 @@ const useCourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, handleDismissNotification, advanceSettingsUrl, + genericSavingStatus, handleSectionDragAndDrop, handleSubsectionDragAndDrop, handleUnitDragAndDrop, diff --git a/src/course-outline/paste-button/PasteButton.jsx b/src/course-outline/paste-button/PasteButton.jsx deleted file mode 100644 index 48604f44eb..0000000000 --- a/src/course-outline/paste-button/PasteButton.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState, useRef } from 'react'; -import PropTypes from 'prop-types'; -import { useSelector } from 'react-redux'; -import { - Hyperlink, Icon, Button, OverlayTrigger, -} from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - FileCopy as PasteIcon, - Question as QuestionIcon, -} from '@openedx/paragon/icons'; -import { getInitialUserClipboard } from '../data/selectors'; -import messages from './messages'; - -const PasteButton = ({ - text, - blockType, - onClick, -}) => { - const intl = useIntl(); - const initialUserClipboard = useSelector(getInitialUserClipboard); - const { - content, - sourceContextTitle, - sourceEditUrl, - } = initialUserClipboard || {}; - // Show button only if clipboard has content - const showPasteButton = ( - content?.status === 'ready' - && content?.blockType === blockType - ); - - const [show, setShow] = useState(false); - const handleOnMouseEnter = () => { - setShow(true); - }; - const handleOnMouseLeave = () => { - setShow(false); - }; - const ref = useRef(null); - - if (!showPasteButton) { - return null; - } - - const renderBlockLink = (props) => ( - -
-

- {content?.displayName}
- - {content?.blockTypeDisplay} - -

- - {intl.formatMessage(messages.clipboardContentFromLabel)} - {sourceContextTitle} - -
-
- ); - - 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.', }, });