diff --git a/package-lock.json b/package-lock.json index 03ddd78906..5b70c9f3e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@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", @@ -2021,18 +2022,20 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.21.0", - "license": "MIT", + "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.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.13.11", - "license": "MIT" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@babel/template": { "version": "7.22.15", @@ -9264,6 +9267,20 @@ "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/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -21963,6 +21980,14 @@ "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", @@ -22192,6 +22217,21 @@ "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", @@ -22204,6 +22244,17 @@ "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", "license": "MIT", @@ -27373,6 +27424,14 @@ "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 7eef34c864..e6436f6a54 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@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/constants.js b/src/constants.js index 7c293fe122..cfb427edb9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -23,6 +23,8 @@ export const NOTIFICATION_MESSAGES = { saving: 'Saving', duplicating: 'Duplicating', deleting: 'Deleting', + copying: 'Copying', + pasting: 'Pasting', empty: '', }; diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 25c6564bce..bceba34666 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -97,6 +97,8 @@ const CourseOutline = ({ courseId }) => { handleSubsectionDragAndDrop, handleVideoSharingOptionChange, handleUnitDragAndDrop, + handleCopyToClipboardClick, + handlePasteClipboardClick, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); @@ -351,6 +353,7 @@ const CourseOutline = ({ courseId }) => { section, section.childInfo.children, )} + onPasteClick={handlePasteClipboardClick} > { subsection, subsection.childInfo.children, )} + onCopyToClipboardClick={handleCopyToClipboardClick} /> ))} diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index e9ac37e25b..a8b51fcfba 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -10,3 +10,4 @@ @import "./configure-modal/ConfigureModal"; @import "./drag-helper/ConditionalSortableElement"; @import "./xblock-status/XBlockStatus"; +@import "./paste-button/PasteButton"; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index be9c62d0a5..20c2efa1c7 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -17,6 +17,7 @@ import { getCourseBlockApiUrl, getCourseItemApiUrl, getXBlockBaseApiUrl, + getClipboardUrl, } from './data/api'; import { RequestStatus } from '../data/constants'; import { @@ -43,6 +44,8 @@ import cardHeaderMessages from './card-header/messages'; import enableHighlightsModalMessages from './enable-highlights-modal/messages'; import statusBarMessages from './status-bar/messages'; import configureModalMessages from './configure-modal/messages'; +import pasteButtonMessages from './paste-button/messages'; +import subsectionMessages from './subsection-card/messages'; let axiosMock; let store; @@ -1338,4 +1341,78 @@ describe('', () => { expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument(); }); }); + + it('check whether unit copy & paste option works correctly', async () => { + const { findAllByTestId } = render(); + // get first section -> first subsection -> first unit element + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [sectionElement] = await findAllByTestId('section-card'); + const [subsection] = section.childInfo.children; + let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + await act(async () => fireEvent.click(expandBtn)); + 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); + // check that initialUserClipboard state is empty + const { initialUserClipboard } = store.getState().courseOutline; + expect(initialUserClipboard).toBeUndefined(); + + // find menu button and click on it to open menu + const menu = await within(unitElement).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + + // move first unit back to second position to test move down option + const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage); + await act(async () => fireEvent.click(copyButton)); + + // check that initialUserClipboard state is updated + expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent); + + [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + // find clipboard content label + const clipboardLabel = await within(subsectionElement).findByText( + pasteButtonMessages.clipboardContentLabel.defaultMessage, + ); + await act(async () => fireEvent.mouseOver(clipboardLabel)); + + // find clipboard content popup link + const clipboardPopover = await within(subsectionElement).findByRole('tooltip'); + expect(clipboardPopover.querySelector('a')).toHaveAttribute('href', unit.studioUrl); + + // check paste button functionality + // mock api call + axiosMock + .onPost(getXBlockBaseApiUrl(), { + parent_locator: subsection.id, + staged_content: 'clipboard', + }).reply(200, { dummy: 'value' }); + const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage); + await act(async () => fireEvent.click(pasteBtn)); + + [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0]; + expect(lastUnitElement).toHaveTextContent(unit.displayName); + }); }); diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index 0e508c5302..2aa188c794 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -261,6 +261,7 @@ module.exports = { ancestorHasStaffLock: true, staffOnlyMessage: false, hasPartitionGroupComponents: false, + enableCopyPasteUnits: true, userPartitionInfo: { selectablePartitions: [ { @@ -292,6 +293,7 @@ module.exports = { ancestorHasStaffLock: true, staffOnlyMessage: false, hasPartitionGroupComponents: false, + enableCopyPasteUnits: true, userPartitionInfo: { selectablePartitions: [ { @@ -391,7 +393,7 @@ module.exports = { }, ancestor_has_staff_lock: false, staff_only_message: false, - enable_copy_paste_units: false, + enable_copy_paste_units: true, has_partition_group_components: false, user_partition_info: { selectable_partitions: [ diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 6adb6f849e..73816d185a 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -32,9 +32,12 @@ const CardHeader = ({ onClickDuplicate, onClickMoveUp, onClickMoveDown, + onClickCopy, titleComponent, namePrefix, actions, + enableCopyPasteUnits, + isVertical, }) => { const intl = useIntl(); const [titleValue, setTitleValue] = useState(title); @@ -106,6 +109,11 @@ const CardHeader = ({ > {intl.formatMessage(messages.menuConfigure)} + {isVertical && enableCopyPasteUnits && ( + + {intl.formatMessage(messages.menuCopy)} + + )} {actions.duplicable && ( `${getApiBaseUrl()}${rein export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`; +export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`; /** * @typedef {Object} courseOutline @@ -412,3 +413,32 @@ 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 + * @returns {Promise} +*/ +export async function pasteBlock(parentLocator) { + const { data } = await getAuthenticatedHttpClient() + .post(getXBlockBaseApiUrl(), { + parent_locator: parentLocator, + staged_content: 'clipboard', + }); + + return data; +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index 3a3a2bb6c7..88d9e9a914 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -8,3 +8,4 @@ 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; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index fd2706f656..6c2da8988f 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -38,12 +38,19 @@ const slice = createSlice({ childAddable: true, duplicable: true, }, + initialUserClipboard: { + content: {}, + sourceUsageKey: null, + sourceContexttitle: null, + sourceEditUrl: null, + }, }, reducers: { fetchOutlineIndexSuccess: (state, { payload }) => { state.outlineIndexData = payload; state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; + state.initialUserClipboard = payload.initialUserClipboard; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { state.loadingStatus = { @@ -69,6 +76,9 @@ const slice = createSlice({ ...payload, }; }, + updateClipboardContent: (state, { payload }) => { + state.initialUserClipboard = payload; + }, updateCourseActions: (state, { payload }) => { state.actions = { ...state.actions, @@ -205,6 +215,7 @@ export const { reorderSectionList, reorderSubsectionList, reorderUnitList, + updateClipboardContent, } = slice.actions; export const { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index e7bc22509a..611aaa2dba 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -28,6 +28,8 @@ import { setSectionOrderList, setVideoSharingOption, setCourseItemOrderList, + copyBlockToClipboard, + pasteBlock, } from './api'; import { addSection, @@ -49,6 +51,7 @@ import { reorderSectionList, reorderSubsectionList, reorderUnitList, + updateClipboardContent, } from './slice'; export function fetchCourseOutlineIndexQuery(courseId) { @@ -371,7 +374,7 @@ export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) { function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); try { await duplicateCourseItem(itemId, parentLocator).then(async (result) => { @@ -560,3 +563,47 @@ export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, rest } }; } + +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 })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); + + try { + await pasteBlock(parentLocator).then(async (result) => { + if (result) { + dispatch(fetchCourseSectionQuery(sectionId, true)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 7754733dcf..5e55be1253 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -6,10 +6,12 @@ import { getConfig } from '@edx/frontend-platform'; import { RequestStatus } from '../data/constants'; import { COURSE_BLOCK_NAMES } from './constants'; +import { useBroadcastChannel } from '../generic/broadcast-channel/hooks'; import { setCurrentItem, setCurrentSection, updateSavingStatus, + updateClipboardContent, } from './data/slice'; import { getLoadingStatus, @@ -48,6 +50,8 @@ import { setVideoSharingOptionQuery, setSubsectionOrderListQuery, setUnitOrderListQuery, + setClipboardContent, + pasteClipboardContent, } from './data/thunk'; const useCourseOutline = ({ courseId }) => { @@ -74,6 +78,17 @@ const useCourseOutline = ({ courseId }) => { const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const clipboardBroadcastChannel = useBroadcastChannel('studio_clipboard_channel', (message) => { + dispatch(updateClipboardContent(message)); + }); + + const handleCopyToClipboardClick = (usageKey) => { + dispatch(setClipboardContent(usageKey, clipboardBroadcastChannel.postMessage)); + }; + + const handlePasteClipboardClick = (parentLocator, sectionId) => { + dispatch(pasteClipboardContent(parentLocator, sectionId)); + }; const handleNewSectionSubmit = () => { dispatch(addNewSectionQuery(courseStructure.id)); @@ -289,6 +304,8 @@ const useCourseOutline = ({ courseId }) => { handleSubsectionDragAndDrop, handleVideoSharingOptionChange, handleUnitDragAndDrop, + handleCopyToClipboardClick, + handlePasteClipboardClick, }; }; diff --git a/src/course-outline/paste-button/PasteButton.jsx b/src/course-outline/paste-button/PasteButton.jsx new file mode 100644 index 0000000000..680b01dd2c --- /dev/null +++ b/src/course-outline/paste-button/PasteButton.jsx @@ -0,0 +1,115 @@ +import { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { + Hyperlink, Icon, Button, OverlayTrigger, +} from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + FileCopy as PasteIcon, + Question as QuestionIcon, +} from '@edx/paragon/icons'; +import { getInitialUserClipboard } from '../data/selectors'; +import messages from './messages'; + +const PasteButton = ({ + text, + blockType, + onClick, +}) => { + const intl = useIntl(); + const initialUserClipboard = useSelector(getInitialUserClipboard); + const { + content, + sourceContextTitle, + sourceEditUrl, + } = initialUserClipboard || {}; + // Show button only if clipboard has content + const showPasteButton = ( + content?.status === 'ready' + && content?.blockType === blockType + ); + + const [show, setShow] = useState(false); + const handleOnMouseEnter = () => { + setShow(true); + }; + const handleOnMouseLeave = () => { + setShow(false); + }; + const ref = useRef(null); + + if (!showPasteButton) { + return null; + } + + const renderBlockLink = (props) => ( + +
+

+ {content?.displayName}
+ + {content?.blockTypeDisplay} + +

+ + {intl.formatMessage(messages.clipboardContentFromLabel)} + {sourceContextTitle} + +
+
+ ); + + return ( + <> + + +
+ + {intl.formatMessage(messages.clipboardContentLabel)} +
+
+ + ); +}; + +PasteButton.propTypes = { + text: PropTypes.string.isRequired, + blockType: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default PasteButton; diff --git a/src/course-outline/paste-button/PasteButton.scss b/src/course-outline/paste-button/PasteButton.scss new file mode 100644 index 0000000000..04d4491816 --- /dev/null +++ b/src/course-outline/paste-button/PasteButton.scss @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 0000000000..0576b500f6 --- /dev/null +++ b/src/course-outline/paste-button/messages.js @@ -0,0 +1,14 @@ +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 f081c9ea4f..a6c524976f 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -8,11 +8,13 @@ import classNames from 'classnames'; 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 BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; 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'; @@ -32,6 +34,7 @@ const SubsectionCard = ({ onNewUnitSubmit, onOrderChange, onOpenConfigureModal, + onPasteClick, }) => { const currentRef = useRef(null); const intl = useIntl(); @@ -47,6 +50,7 @@ const SubsectionCard = ({ visibilityState, actions: subsectionActions, isHeaderVisible = true, + enableCopyPasteUnits = false, } = subsection; // re-create actions object for customizations @@ -91,6 +95,7 @@ const SubsectionCard = ({ }; const handleNewButtonClick = () => onNewUnitSubmit(id); + const handlePasteButtonClick = () => onPasteClick(id, section.id); const titleComponent = ( {children} {actions.childAddable && ( - + <> + + {enableCopyPasteUnits && ( + + )} + )} )} @@ -214,6 +228,7 @@ SubsectionCard.propTypes = { hasChanges: PropTypes.bool.isRequired, visibilityState: PropTypes.string.isRequired, shouldScroll: PropTypes.bool, + enableCopyPasteUnits: PropTypes.bool, actions: PropTypes.shape({ deletable: PropTypes.bool.isRequired, draggable: PropTypes.bool.isRequired, @@ -235,6 +250,7 @@ SubsectionCard.propTypes = { canMoveItem: PropTypes.func.isRequired, onOrderChange: PropTypes.func.isRequired, onOpenConfigureModal: PropTypes.func.isRequired, + onPasteClick: PropTypes.func.isRequired, }; export default SubsectionCard; diff --git a/src/course-outline/subsection-card/messages.js b/src/course-outline/subsection-card/messages.js index 90ca407b1b..b4a0b5661a 100644 --- a/src/course-outline/subsection-card/messages.js +++ b/src/course-outline/subsection-card/messages.js @@ -5,6 +5,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.subsection.button.new-unit', defaultMessage: 'New unit', }, + pasteButton: { + id: 'course-authoring.course-outline.subsection.button.new-unit', + defaultMessage: 'Paste unit', + }, }); export default messages; diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 1601b54e4f..1cb95f718a 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -28,6 +28,7 @@ const UnitCard = ({ onDuplicateSubmit, getTitleLink, onOrderChange, + onCopyToClipboardClick, }) => { const currentRef = useRef(null); const dispatch = useDispatch(); @@ -42,6 +43,7 @@ const UnitCard = ({ visibilityState, actions: unitActions, isHeaderVisible = true, + enableCopyPasteUnits = false, } = unit; // re-create actions object for customizations @@ -80,6 +82,10 @@ const UnitCard = ({ onOrderChange(index, index + 1); }; + const handleCopyClick = () => { + onCopyToClipboardClick(unit.id); + }; + const titleComponent = (
', () => { expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument(); expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument(); }); + + it('shows copy option based on enableCopyPasteUnits flag', async () => { + const { findByTestId } = renderComponent({ + unit: { + ...unit, + enableCopyPasteUnits: true, + }, + }); + const element = await findByTestId('unit-card'); + const menu = await within(element).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + expect(within(element).queryByText(cardMessages.menuCopy.defaultMessage)).toBeInTheDocument(); + }); }); diff --git a/src/generic/broadcast-channel/hooks.js b/src/generic/broadcast-channel/hooks.js new file mode 100644 index 0000000000..230c153d18 --- /dev/null +++ b/src/generic/broadcast-channel/hooks.js @@ -0,0 +1,46 @@ +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, + }; +};