diff --git a/package-lock.json b/package-lock.json index b3dc19751..68b552617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,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", @@ -7742,36 +7741,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", @@ -16214,14 +16183,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", @@ -16393,21 +16354,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", @@ -16420,17 +16366,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", @@ -21774,14 +21709,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 371b6885b..2c7779533 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,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/__mocks__/clipboardUnit.js b/src/__mocks__/clipboardUnit.js new file mode 100644 index 000000000..fb20bde41 --- /dev/null +++ b/src/__mocks__/clipboardUnit.js @@ -0,0 +1,16 @@ +export default { + 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/__mocks__/clipboardXBlock.js b/src/__mocks__/clipboardXBlock.js new file mode 100644 index 000000000..ecaf0b50b --- /dev/null +++ b/src/__mocks__/clipboardXBlock.js @@ -0,0 +1,16 @@ +export default { + 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/__mocks__/index.js b/src/__mocks__/index.js new file mode 100644 index 000000000..b3b5984d3 --- /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 47c441b8a..87fb9d9cb 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 STRUCTURAL_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course']; diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 4d52c3987..19bdb37c4 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 51e50ee8d..ce85ec1b9 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 6b12cf62d..7d6634b55 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 e0a0f3843..a57288e53 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 5f5369cb0..4214ddbd7 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 932ba5c4d..4819498e8 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 83290de0a..14a814065 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 48604f44e..000000000 --- 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 ( - <> - - {text} - - - - - {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 04d449181..000000000 --- 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 0576b500f..000000000 --- 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 7814eb99a..7f31b4b1f 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 d76d305ae..167be766a 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 9df9a9be2..57f5e5217 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,6 +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 } from './clipboard'; const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); @@ -40,9 +42,13 @@ const CourseUnit = ({ courseId }) => { savingStatus, isTitleEditFormOpen, isErrorAlert, + staticFileNotices, currentlyVisibleToStudents, isInternetConnectionAlertFailed, unitXBlockActions, + sharedClipboardData, + showPasteXBlock, + showPasteUnit, handleTitleEditSubmit, headerNavigationsActions, handleTitleEdit, @@ -50,6 +56,7 @@ const CourseUnit = ({ courseId }) => { handleCreateNewCourseXBlock, handleConfigureSubmit, courseVerticalChildren, + canPasteComponent, } = useCourseUnit({ courseId, blockId }); document.title = getPageHeadTitle('', unitTitle); @@ -103,6 +110,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 +156,13 @@ const CourseUnit = ({ courseId }) => { blockId={blockId} handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} /> + {showPasteXBlock && canPasteComponent && ( + + )} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 24d55a9e1..4393b50a9 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,7 @@ import { postXBlockBaseApiUrl, } from './data/api'; import { + createNewCourseXBlock, deleteUnitItemQuery, editCourseUnitVisibilityAndData, fetchCourseSectionVerticalData, @@ -30,8 +31,16 @@ import { courseUnitIndexMock, courseUnitMock, courseVerticalChildrenMock, + clipboardMockResponse, } from './__mocks__'; +import { + clipboardUnit, + clipboardXBlock, +} from '../__mocks__'; import { executeThunk } from '../utils'; +import deleteModalMessages from '../generic/delete-modal/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'; import courseSequenceMessages from './course-sequence/messages'; @@ -39,13 +48,12 @@ 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'; import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; -import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; import messages from './messages'; +import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; let axiosMock; let store; @@ -55,6 +63,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 +99,13 @@ jest.mock('@tanstack/react-query', () => ({ })), })); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const RootWrapper = () => ( @@ -104,7 +124,7 @@ describe('', () => { roles: [], }, }); - + global.localStorage.clear(); store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock @@ -311,7 +331,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 +1033,413 @@ 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: messages.pasteButtonText.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: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); + + const whatsInClipboardText = getByText( + pasteComponentMessages.pasteButtonWhatsInClipboardText.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: messages.pasteButtonText.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, + }); + + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + 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 () => { + const { + getAllByTestId, getByRole, + } = render(); + + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + 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: 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, + }); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + 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: 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, + }); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + 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: 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, + }); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardUnit, + }); + + 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: 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: messages.pasteButtonText.defaultMessage, + })).not.toBeInTheDocument(); + expect(queryByText( + pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, + )).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/course-unit/__mocks__/clipboardResponse.js b/src/course-unit/__mocks__/clipboardResponse.js new file mode 100644 index 000000000..1d8a5a64d --- /dev/null +++ b/src/course-unit/__mocks__/clipboardResponse.js @@ -0,0 +1,9 @@ +export default { + 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__/courseVerticalChildren.js b/src/course-unit/__mocks__/courseVerticalChildren.js index a6d8102dc..32bd8272b 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 d8c220b7a..8810e61e0 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -3,3 +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 clipboardMockResponse } from './clipboardResponse'; diff --git a/src/course-unit/clipboard/index.js b/src/course-unit/clipboard/index.js new file mode 100644 index 000000000..22e541cc9 --- /dev/null +++ b/src/course-unit/clipboard/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as PasteNotificationAlert } from './paste-notification'; 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 000000000..bc9dcc6da --- /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 }) => ( + + {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 000000000..f5e8c55a9 --- /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 000000000..f3f9e3bea --- /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 000000000..ccee5ba49 --- /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 000000000..a44ab2276 --- /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 000000000..260acdd20 --- /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 000000000..81f37e4d5 --- /dev/null +++ b/src/course-unit/clipboard/paste-notification/messages.js @@ -0,0 +1,52 @@ +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.', + 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.', + }, +}); + +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 000000000..d8d112267 --- /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/course-sequence/CourseSequence.scss b/src/course-unit/course-sequence/CourseSequence.scss index 21bf490d1..9a0a34004 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 7729bba5a..f8a7ea007 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 7c3378707..0f7019ae2 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 a2fbe55d4..0fa15fa29 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 e601ce2f3..2e2923d27 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 && ( + + {intl.formatMessage(messages.pasteAsNewUnitLink)} + + )} ); @@ -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 7565a8c0d..77372988f 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 } /> ))} {intl.formatMessage(messages.newUnitBtnText)} + {showPasteUnit && ( + + {intl.formatMessage(messages.pasteAsNewUnitLink)} + + )} {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 3f885692b..46ba76e32 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 '../../generic/data/thunks'; 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 1b78bfcc9..3e1652de1 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 3ec12cef4..d21a95b78 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -11,7 +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 postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; /** @@ -60,13 +59,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 +73,7 @@ export async function createCourseXblock({ category: category || type, parent_locator: parentLocator, display_name: displayName, + staged_content: stagedContent, }; const { data } = await getAuthenticatedHttpClient() diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index 16619548f..e9e98bc0c 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -1,4 +1,11 @@ +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; +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 +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; +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 436a957b2..02edc0975 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, @@ -16,7 +17,8 @@ const slice = createSlice({ }, unit: {}, courseSectionVertical: {}, - courseVerticalChildren: {}, + courseVerticalChildren: { children: [], isPublished: true }, + staticFileNotices: {}, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { @@ -95,6 +97,9 @@ const slice = createSlice({ }), }; }, + fetchStaticFileNoticesSuccess: (state, { payload }) => { + state.staticFileNotices = payload; + }, }, }); @@ -115,6 +120,7 @@ export const { updateCourseVerticalChildrenLoadingStatus, deleteXBlock, duplicateXBlock, + fetchStaticFileNoticesSuccess, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 6d6353188..e94287c03 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -7,6 +7,7 @@ import { import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { updateModel, updateModels } from '../../generic/model-store'; +import { updateClipboardData } from '../../generic/data/slice'; import { getCourseUnitData, editUnitDisplayName, @@ -32,6 +33,7 @@ import { updateQueryPendingStatus, deleteXBlock, duplicateXBlock, + fetchStaticFileNoticesSuccess, } from './slice'; import { getNotificationMessage } from './utils'; @@ -68,6 +70,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 +144,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 +160,14 @@ 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)); + dispatch(fetchStaticFileNoticesSuccess(formattedResult.staticFileNotices)); + + if (body.parentLocator.includes('vertical')) { + localStorage.removeItem('staticFileNotices'); + } + } const courseVerticalChildrenData = await getCourseVerticalChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); @@ -162,8 +180,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 +211,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()); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index a2b0726e7..335593db2 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 '../generic/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/messages.js b/src/course-unit/messages.js index ba27b0fb7..4f0418efe 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/Sidebar.scss b/src/course-unit/sidebar/Sidebar.scss index 0fbae7eb6..f60bfe087 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 ac0a63287..5f78ae761 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 '../../../../generic/data/thunks'; 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) && ( - + {intl.formatMessage(messages.actionButtonDiscardChangesTitle)} )} - {enableCopyPasteUnits && ( - - {intl.formatMessage(messages.actionButtonCopyUnitTitle)} - + {enableCopyPasteUnits && canEdit && ( + <> + + dispatch(copyToClipboard(id))} + variant="outline-primary" + size="sm" + > + {intl.formatMessage(messages.actionButtonCopyUnitTitle)} + + > )} > ); 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 000000000..2289968c1 --- /dev/null +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -0,0 +1,80 @@ +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 { 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('../../../../generic/data/thunks', () => ({ + ...jest.requireActual('../../../../generic/data/thunks'), + 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/broadcast-channel/hooks.js b/src/generic/broadcast-channel/hooks.js deleted file mode 100644 index 230c153d1..000000000 --- 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/generic/clipboard/hooks/useCopyToClipboard.js b/src/generic/clipboard/hooks/useCopyToClipboard.js new file mode 100644 index 000000000..86303fab9 --- /dev/null +++ b/src/generic/clipboard/hooks/useCopyToClipboard.js @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants'; +import { getClipboardData } from '../../data/selectors'; + +/** + * 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 [sharedClipboardData, setSharedClipboardData] = useState({}); + const clipboardData = useSelector(getClipboardData); + + // 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 && !STRUCTURAL_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/generic/clipboard/hooks/useCopyToClipboard.test.jsx b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx new file mode 100644 index 000000000..2e57f409d --- /dev/null +++ b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx @@ -0,0 +1,122 @@ +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 { clipboardUnit, clipboardXBlock } from '../../../__mocks__'; +import { copyToClipboard } from '../../data/thunks'; +import { getClipboardUrl } from '../../data/api'; +import useCopyToClipboard from './useCopyToClipboard'; + +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(() => useCopyToClipboard(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(() => useCopyToClipboard(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(() => useCopyToClipboard(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(() => useCopyToClipboard(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(() => useCopyToClipboard(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/generic/clipboard/index.js b/src/generic/clipboard/index.js new file mode 100644 index 000000000..2716b10c4 --- /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/generic/clipboard/paste-component/PasteComponent.scss b/src/generic/clipboard/paste-component/PasteComponent.scss new file mode 100644 index 000000000..c68ed4e4c --- /dev/null +++ b/src/generic/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/generic/clipboard/paste-component/components/PasteButton.jsx b/src/generic/clipboard/paste-component/components/PasteButton.jsx new file mode 100644 index 000000000..a13dc28c6 --- /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 ( + + {text} + + ); +}; + +PasteButton.propTypes = { + onClick: PropsTypes.func.isRequired, + text: PropsTypes.string.isRequired, + className: PropsTypes.string, +}; + +PasteButton.defaultProps = { + className: undefined, +}; + +export default PasteButton; diff --git a/src/generic/clipboard/paste-component/components/PopoverContent.jsx b/src/generic/clipboard/paste-component/components/PopoverContent.jsx new file mode 100644 index 000000000..70193c3ea --- /dev/null +++ b/src/generic/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/generic/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx new file mode 100644 index 000000000..aca6d3f0c --- /dev/null +++ b/src/generic/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.pasteButtonWhatsInClipboardText)} + + + ); +}; + +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/generic/clipboard/paste-component/components/index.js b/src/generic/clipboard/paste-component/components/index.js new file mode 100644 index 000000000..1336513b3 --- /dev/null +++ b/src/generic/clipboard/paste-component/components/index.js @@ -0,0 +1,3 @@ +export { default as WhatsInClipboard } from './WhatsInClipboard'; +export { default as PasteButton } from './PasteButton'; +export { default as PopoverContent } from './PopoverContent'; diff --git a/src/generic/clipboard/paste-component/constants.js b/src/generic/clipboard/paste-component/constants.js new file mode 100644 index 000000000..1dc7a1c52 --- /dev/null +++ b/src/generic/clipboard/paste-component/constants.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +/* eslint-disable import/prefer-default-export */ +export const clipboardPropsTypes = { + sourceEditUrl: PropTypes.string.isRequired, + content: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + blockTypeDisplay: PropTypes.string.isRequired, + }).isRequired, + sourceContextTitle: PropTypes.string.isRequired, +}; diff --git a/src/generic/clipboard/paste-component/index.jsx b/src/generic/clipboard/paste-component/index.jsx new file mode 100644 index 000000000..a6602bb07 --- /dev/null +++ b/src/generic/clipboard/paste-component/index.jsx @@ -0,0 +1,65 @@ +import { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { OverlayTrigger, Popover } from '@openedx/paragon'; + +import { PopoverContent, PasteButton, WhatsInClipboard } from './components'; +import { clipboardPropsTypes } from './constants'; + +const PasteComponent = ({ + onClick, clipboardData, text, className, +}) => { + 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 = { + 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 000000000..77fbcba2b --- /dev/null +++ b/src/generic/clipboard/paste-component/messages.js @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +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.', + }, +}); + +export default messages; diff --git a/src/generic/data/api.js b/src/generic/data/api.js index 6cec7b915..83fd561ff 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 461e09fe9..e111961b1 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 a25112704..f53ddc610 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 0008a187f..f5cc8a955 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/divider/Divider.jsx b/src/generic/divider/Divider.jsx new file mode 100644 index 000000000..6b75eff3d --- /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 000000000..b78206689 --- /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 000000000..ca4fc1636 --- /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 0ef6a6202..9c74c5b8b 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -6,6 +6,8 @@ @import "./create-or-rerun-course/CreateOrRerunCourseForm"; @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";
+ {intl.formatMessage(messages.pasteButtonWhatsInClipboardText)} +