From ad17c456cb2f365f0e704538bbc19db0fbbe2020 Mon Sep 17 00:00:00 2001 From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com> Date: Wed, 20 Mar 2024 18:48:18 +0200 Subject: [PATCH] refactor: [AXIMST-658] Course unit - Copy/paste refactoring (#204) * refactor: copy paste functional refactoring * refactor: refactoring paste-button * refactor: tests refactoring * refactor: updated translations * refactor: refactoring after review * refactor: renamed status selector name --- package-lock.json | 57 --------- package.json | 1 - .../__mocks__/clipboardUnit.js | 0 .../__mocks__/clipboardXBlock.js | 0 src/__mocks__/index.js | 2 + src/constants.js | 11 ++ src/course-outline/CourseOutline.scss | 1 - src/course-outline/CourseOutline.test.jsx | 46 +++---- src/course-outline/data/api.js | 15 +-- src/course-outline/data/selectors.js | 1 - src/course-outline/data/slice.js | 11 -- src/course-outline/data/thunk.js | 28 +---- src/course-outline/hooks.jsx | 16 +-- .../paste-button/PasteButton.jsx | 115 ------------------ .../paste-button/PasteButton.scss | 20 --- src/course-outline/paste-button/messages.js | 14 --- .../subsection-card/SubsectionCard.jsx | 13 +- .../subsection-card/SubsectionCard.test.jsx | 8 +- src/course-unit/CourseUnit.jsx | 4 +- src/course-unit/CourseUnit.scss | 1 - src/course-unit/CourseUnit.test.jsx | 24 ++-- src/course-unit/__mocks__/index.js | 2 - src/course-unit/clipboard/index.js | 3 +- .../components/PasteComponentButton.jsx | 33 ----- .../clipboard/paste-component/messages.js | 18 --- .../course-xblock/CourseXBlock.jsx | 2 +- src/course-unit/data/api.js | 25 ---- src/course-unit/data/selectors.js | 4 +- src/course-unit/data/slice.js | 5 - src/course-unit/data/thunk.js | 40 +----- src/course-unit/hooks.jsx | 2 +- src/course-unit/messages.js | 4 + .../sidebar-footer/ActionButtons.jsx | 2 +- .../sidebar-footer/ActionButtons.test.jsx | 13 +- src/generic/broadcast-channel/hooks.js | 46 ------- .../clipboard/hooks/useCopyToClipboard.js} | 15 ++- .../hooks/useCopyToClipboard.test.jsx} | 17 +-- src/generic/clipboard/index.js | 2 + .../paste-component/PasteComponent.scss | 0 .../components/PasteButton.jsx | 36 ++++++ .../components/PopoverContent.jsx | 0 .../components/WhatsInClipboard.jsx | 2 +- .../paste-component/components/index.js | 2 +- .../clipboard/paste-component/constants.js | 0 .../clipboard/paste-component/index.jsx | 17 ++- .../clipboard/paste-component/messages.js | 14 +++ src/generic/data/api.js | 24 ++++ src/generic/data/selectors.js | 1 + src/generic/data/slice.js | 5 + src/generic/data/thunks.js | 48 +++++++- .../saving-error-alert/SavingErrorAlert.jsx | 5 +- src/generic/styles.scss | 1 + src/i18n/messages/ar.json | 2 - src/i18n/messages/de.json | 2 - src/i18n/messages/de_DE.json | 2 - src/i18n/messages/es_419.json | 2 - src/i18n/messages/fa_IR.json | 2 - src/i18n/messages/fr.json | 2 - src/i18n/messages/fr_CA.json | 2 - src/i18n/messages/hi.json | 2 - src/i18n/messages/it.json | 2 - src/i18n/messages/it_IT.json | 2 - src/i18n/messages/pt.json | 2 - src/i18n/messages/pt_PT.json | 2 - src/i18n/messages/ru.json | 2 - src/i18n/messages/uk.json | 2 - src/i18n/messages/zh_CN.json | 2 - 67 files changed, 260 insertions(+), 546 deletions(-) rename src/{course-unit => }/__mocks__/clipboardUnit.js (100%) rename src/{course-unit => }/__mocks__/clipboardXBlock.js (100%) create mode 100644 src/__mocks__/index.js delete mode 100644 src/course-outline/paste-button/PasteButton.jsx delete mode 100644 src/course-outline/paste-button/PasteButton.scss delete mode 100644 src/course-outline/paste-button/messages.js delete mode 100644 src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx delete mode 100644 src/course-unit/clipboard/paste-component/messages.js delete mode 100644 src/generic/broadcast-channel/hooks.js rename src/{course-unit/clipboard/hooks/useClipboard.jsx => generic/clipboard/hooks/useCopyToClipboard.js} (74%) rename src/{course-unit/clipboard/hooks/useClipboard.test.jsx => generic/clipboard/hooks/useCopyToClipboard.test.jsx} (85%) create mode 100644 src/generic/clipboard/index.js rename src/{course-unit => generic}/clipboard/paste-component/PasteComponent.scss (100%) create mode 100644 src/generic/clipboard/paste-component/components/PasteButton.jsx rename src/{course-unit => generic}/clipboard/paste-component/components/PopoverContent.jsx (100%) rename src/{course-unit => generic}/clipboard/paste-component/components/WhatsInClipboard.jsx (95%) rename src/{course-unit => generic}/clipboard/paste-component/components/index.js (63%) rename src/{course-unit => generic}/clipboard/paste-component/constants.js (100%) rename src/{course-unit => generic}/clipboard/paste-component/index.jsx (78%) create mode 100644 src/generic/clipboard/paste-component/messages.js diff --git a/package-lock.json b/package-lock.json index c042443763..f70b3b866f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "@openedx/paragon": "^21.5.7", "@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", @@ -9551,20 +9550,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/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -20663,14 +20648,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", @@ -20900,21 +20877,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", @@ -20927,17 +20889,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", "license": "MIT", @@ -25979,14 +25930,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 4ffdd0ac48..c3817c89a3 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "@openedx/paragon": "^21.5.7", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", - "broadcast-channel": "^7.0.0", "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", diff --git a/src/course-unit/__mocks__/clipboardUnit.js b/src/__mocks__/clipboardUnit.js similarity index 100% rename from src/course-unit/__mocks__/clipboardUnit.js rename to src/__mocks__/clipboardUnit.js diff --git a/src/course-unit/__mocks__/clipboardXBlock.js b/src/__mocks__/clipboardXBlock.js similarity index 100% rename from src/course-unit/__mocks__/clipboardXBlock.js rename to src/__mocks__/clipboardXBlock.js diff --git a/src/__mocks__/index.js b/src/__mocks__/index.js new file mode 100644 index 0000000000..b3b5984d3e --- /dev/null +++ b/src/__mocks__/index.js @@ -0,0 +1,2 @@ +export { default as clipboardUnit } from './clipboardUnit'; +export { default as clipboardXBlock } from './clipboardXBlock'; diff --git a/src/constants.js b/src/constants.js index 8a2c605781..ef9d6888ad 100644 --- a/src/constants.js +++ b/src/constants.js @@ -58,3 +58,14 @@ export const COURSE_BLOCK_NAMES = /** @type {const} */ ({ }); export const UPLOAD_FILE_MAX_SIZE = 20 * 1000 * 1000; // 20mb + +export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel'; + +export const CLIPBOARD_STATUS = { + loading: 'loading', + ready: 'ready', + expired: 'expired', + error: 'error', +}; + +export const NOT_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course']; diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 1dad6b4a51..f23746a25d 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -10,4 +10,3 @@ @import "./configure-modal/ConfigureModal"; @import "./drag-helper/SortableItem"; @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 51e50ee8d1..0dafbe33db 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,18 @@ 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 pasteButtonMessages from '../generic/clipboard/paste-component/messages'; import configureModalMessages from '../generic/configure-modal/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 { @@ -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, findAllByRole, queryByTestId } = render(); // get first section -> first subsection -> first unit element const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); @@ -2091,27 +2099,11 @@ describe('', () => { const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); - const expectedClipboardContent = { - content: { - blockType: 'vertical', - blockTypeDisplay: 'Unit', - created: '2024-01-29T07:58:36.844249Z', - displayName: unit.displayName, - id: 15, - olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx', - purpose: 'clipboard', - status: 'ready', - userId: 3, - }, - sourceUsageKey: unit.id, - sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName, - sourceEditUrl: unit.studioUrl, - }; // mock api call axiosMock .onPost(getClipboardUrl(), { usage_key: unit.id, - }).reply(200, expectedClipboardContent); + }).reply(200, clipboardUnit); // check that initialUserClipboard state is empty const { initialUserClipboard } = store.getState().courseOutline; expect(initialUserClipboard).toBeUndefined(); @@ -2125,19 +2117,19 @@ describe('', () => { await act(async () => fireEvent.click(copyButton)); // check that initialUserClipboard state is updated - expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent); + expect(store.getState().generic.clipboardData).toEqual(clipboardUnit); [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); // find clipboard content label const clipboardLabel = await within(subsectionElement).findByText( - pasteButtonMessages.clipboardContentLabel.defaultMessage, + pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage, ); await act(async () => fireEvent.mouseOver(clipboardLabel)); - // find clipboard content popup link - expect( - subsectionElement.querySelector('#vertical-paste-button-overlay'), - ).toHaveAttribute('href', unit.studioUrl); + // find clipboard content popover link + const popoverContent = queryByTestId('popover-content'); + expect(popoverContent.tagName).toBe('A'); + expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`); // check paste button functionality // mock api call diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index 6b12cf62d1..32e22cd96c 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -29,6 +29,7 @@ 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/`; +export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href; /** * @typedef {Object} courseOutline @@ -434,20 +435,6 @@ export async function setVideoSharingOption(courseId, videoSharingOption) { return data; } -/** - * Copy block to clipboard - * @param {string} usageKey - * @returns {Promise} -*/ -export async function copyBlockToClipboard(usageKey) { - const { data } = await getAuthenticatedHttpClient() - .post(getClipboardUrl(), { - usage_key: usageKey, - }); - - return camelCaseObject(data); -} - /** * Paste block to clipboard * @param {string} parentLocator diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index e0a0f3843f..a57288e538 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -8,6 +8,5 @@ export const getCurrentSection = (state) => state.courseOutline.currentSection; export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection; export const getCourseActions = (state) => state.courseOutline.actions; export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; -export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard; export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; export const getPasteFileNotices = (state) => state.courseOutline.pasteFileNotices; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 5f5369cb00..4214ddbd7c 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -38,12 +38,6 @@ const slice = createSlice({ childAddable: true, duplicable: true, }, - initialUserClipboard: { - content: {}, - sourceUsageKey: null, - sourceContexttitle: null, - sourceEditUrl: null, - }, enableProctoredExams: false, pasteFileNotices: {}, }, @@ -52,7 +46,6 @@ const slice = createSlice({ state.outlineIndexData = payload; state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; - state.initialUserClipboard = payload.initialUserClipboard; state.enableProctoredExams = payload.courseStructure?.enableProctoredExams; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { @@ -79,9 +72,6 @@ const slice = createSlice({ ...payload, }; }, - updateClipboardContent: (state, { payload }) => { - state.initialUserClipboard = payload; - }, updateCourseActions: (state, { payload }) => { state.actions = { ...state.actions, @@ -210,7 +200,6 @@ export const { reorderSectionList, reorderSubsectionList, reorderUnitList, - updateClipboardContent, setPasteFileNotices, removePasteFileNotices, } = slice.actions; diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 932ba5c4de..4819498e8c 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -1,4 +1,5 @@ import { RequestStatus } from '../../data/constants'; +import { updateClipboardData } from '../../generic/data/slice'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { COURSE_BLOCK_NAMES } from '../constants'; import { @@ -28,7 +29,6 @@ import { setSectionOrderList, setVideoSharingOption, setCourseItemOrderList, - copyBlockToClipboard, pasteBlock, dismissNotification, } from './api'; @@ -50,7 +50,6 @@ import { deleteUnit, duplicateSection, reorderSectionList, - updateClipboardContent, setPasteFileNotices, } from './slice'; @@ -70,6 +69,7 @@ export function fetchCourseOutlineIndexQuery(courseId) { }, } = outlineIndex; dispatch(fetchOutlineIndexSuccess(outlineIndex)); + dispatch(updateClipboardData(outlineIndex.initialUserClipboard)); dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging, @@ -607,30 +607,6 @@ export function setUnitOrderListQuery( }; } -export function setClipboardContent(usageKey, broadcastClipboard) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); - - try { - await copyBlockToClipboard(usageKey).then(async (result) => { - const status = result?.content?.status; - if (status === 'ready') { - dispatch(updateClipboardContent(result)); - broadcastClipboard(result); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(hideProcessingNotification()); - } else { - throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); - } - }); - } catch (error) { - dispatch(hideProcessingNotification()); - dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - } - }; -} - export function pasteClipboardContent(parentLocator, sectionId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 83290de0ac..ff78544ccf 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, @@ -361,6 +360,7 @@ const useCourseOutline = ({ courseId }) => { handleSectionDragAndDrop, handleSubsectionDragAndDrop, handleUnitDragAndDrop, + genericSavingStatus, }; }; diff --git a/src/course-outline/paste-button/PasteButton.jsx b/src/course-outline/paste-button/PasteButton.jsx deleted file mode 100644 index 48604f44eb..0000000000 --- a/src/course-outline/paste-button/PasteButton.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState, useRef } from 'react'; -import PropTypes from 'prop-types'; -import { useSelector } from 'react-redux'; -import { - Hyperlink, Icon, Button, OverlayTrigger, -} from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - FileCopy as PasteIcon, - Question as QuestionIcon, -} from '@openedx/paragon/icons'; -import { getInitialUserClipboard } from '../data/selectors'; -import messages from './messages'; - -const PasteButton = ({ - text, - blockType, - onClick, -}) => { - const intl = useIntl(); - const initialUserClipboard = useSelector(getInitialUserClipboard); - const { - content, - sourceContextTitle, - sourceEditUrl, - } = initialUserClipboard || {}; - // Show button only if clipboard has content - const showPasteButton = ( - content?.status === 'ready' - && content?.blockType === blockType - ); - - const [show, setShow] = useState(false); - const handleOnMouseEnter = () => { - setShow(true); - }; - const handleOnMouseLeave = () => { - setShow(false); - }; - const ref = useRef(null); - - if (!showPasteButton) { - return null; - } - - const renderBlockLink = (props) => ( - -
-

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

- - {intl.formatMessage(messages.clipboardContentFromLabel)} - {sourceContextTitle} - -
-
- ); - - return ( - <> - - -
- - {intl.formatMessage(messages.clipboardContentLabel)} -
-
- - ); -}; - -PasteButton.propTypes = { - text: PropTypes.string.isRequired, - blockType: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, -}; - -export default PasteButton; diff --git a/src/course-outline/paste-button/PasteButton.scss b/src/course-outline/paste-button/PasteButton.scss deleted file mode 100644 index 04d4491816..0000000000 --- a/src/course-outline/paste-button/PasteButton.scss +++ /dev/null @@ -1,20 +0,0 @@ -// adds bottom arrow to popup link -.popup-link { - position: relative; - - &::after { - content: ""; - position: absolute; - top: 100%; - left: 50%; - width: 0; - height: 0; - border-top: solid .5rem white; - border-left: solid .5rem transparent; - border-right: solid .5rem transparent; - } -} - -.cursor-help { - cursor: help !important; -} diff --git a/src/course-outline/paste-button/messages.js b/src/course-outline/paste-button/messages.js deleted file mode 100644 index 0576b500f6..0000000000 --- a/src/course-outline/paste-button/messages.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - clipboardContentFromLabel: { - id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.from-label', - defaultMessage: 'From: ', - }, - clipboardContentLabel: { - id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.label', - defaultMessage: 'What\'s in my clipboard?', - }, -}); - -export default messages; diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 441a4e34f3..1aa5ea3443 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); @@ -213,10 +213,11 @@ const SubsectionCard = ({ > {intl.formatMessage(messages.newUnitButton)} - {enableCopyPasteUnits && ( - )} diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx index d76d305aed..167be766a9 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { act, render, fireEvent, within, @@ -26,6 +25,13 @@ jest.mock('react-router-dom', () => ({ }), })); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const section = { id: '123', displayName: 'Section Name', diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index dcd5c1eb47..2193f8ada0 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -12,6 +12,7 @@ import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; import getPageHeadTitle from '../generic/utils'; import AlertMessage from '../generic/alert-message'; +import { PasteComponent } from '../generic/clipboard'; import ProcessingNotification from '../generic/processing-notification'; import { SavingErrorAlert } from '../generic/saving-error-alert'; import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; @@ -177,7 +178,8 @@ const CourseUnit = ({ courseId }) => { {showPasteXBlock && canPasteComponent && ( )} diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 9e7b8d1c92..277ed3a9c7 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -4,7 +4,6 @@ @import "./course-xblock/CourseXBlock"; @import "./sidebar/Sidebar"; @import "./course-xblock/CourseXblock"; -@import "./clipboard/paste-component/PasteComponent"; @import "./header-title/HeaderTitle"; .course-unit__alert { diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 17bec284f2..dc9e2bcd66 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -17,7 +17,6 @@ import { postXBlockBaseApiUrl, } from './data/api'; import { - copyToClipboard, createNewCourseXBlock, deleteUnitItemQuery, editCourseUnitVisibilityAndData, @@ -27,8 +26,6 @@ import { } from './data/thunk'; import initializeStore from '../store'; import { - clipboardUnit, - clipboardXBlock, courseCreateXblockMock, courseSectionVerticalMock, courseUnitIndexMock, @@ -36,9 +33,13 @@ import { courseVerticalChildrenMock, clipboardMockResponse, } from './__mocks__'; +import { + clipboardUnit, + clipboardXBlock, +} from '../__mocks__'; import { executeThunk } from '../utils'; import deleteModalMessages from '../generic/delete-modal/messages'; -import pasteComponentMessages from './clipboard/paste-component/messages'; +import pasteComponentMessages from '../generic/clipboard/paste-component/messages'; import pasteNotificationsMessages from './clipboard/paste-notification/messages'; import headerNavigationsMessages from './header-navigations/messages'; import headerTitleMessages from './header-title/messages'; @@ -53,6 +54,7 @@ import CourseUnit from './CourseUnit'; import messages from './messages'; import configureModalMessages from '../generic/configure-modal/messages'; import { RequestStatus } from '../data/constants'; +import { copyToClipboard } from '../generic/data/thunks'; let axiosMock; let store; @@ -1037,7 +1039,7 @@ describe('', () => { await waitFor(() => { expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull(); - expect(queryByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeNull(); + expect(queryByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeNull(); }); axiosMock @@ -1070,10 +1072,10 @@ describe('', () => { }); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); const whatsInClipboardText = getByText( - pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage, + pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, ); userEvent.hover(whatsInClipboardText); @@ -1119,7 +1121,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(copyToClipboard(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })); + userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); await waitFor(() => { expect(getAllByTestId('course-xblock')).toHaveLength(2); @@ -1162,7 +1164,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(copyToClipboard(blockId), store.dispatch); - expect(getByRole('button', { name: pasteComponentMessages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); }); it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { @@ -1428,10 +1430,10 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); expect(queryByRole('button', { - name: pasteComponentMessages.pasteComponentButtonText.defaultMessage, + name: messages.pasteButtonText.defaultMessage, })).not.toBeInTheDocument(); expect(queryByText( - pasteComponentMessages.pasteComponentWhatsInClipboardText.defaultMessage, + pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, )).not.toBeInTheDocument(); }); }); diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js index 88072ae83e..8810e61e07 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -3,6 +3,4 @@ export { default as courseSectionVerticalMock } from './courseSectionVertical'; export { default as courseUnitMock } from './courseUnit'; export { default as courseCreateXblockMock } from './courseCreateXblock'; export { default as courseVerticalChildrenMock } from './courseVerticalChildren'; -export { default as clipboardUnit } from './clipboardUnit'; -export { default as clipboardXBlock } from './clipboardXBlock'; export { default as clipboardMockResponse } from './clipboardResponse'; diff --git a/src/course-unit/clipboard/index.js b/src/course-unit/clipboard/index.js index 4b2f009321..22e541cc9e 100644 --- a/src/course-unit/clipboard/index.js +++ b/src/course-unit/clipboard/index.js @@ -1,3 +1,2 @@ -export { default as PasteComponent } from './paste-component'; +// eslint-disable-next-line import/prefer-default-export export { default as PasteNotificationAlert } from './paste-notification'; -export { default as useCopyToClipboard } from './hooks/useClipboard'; diff --git a/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx b/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx deleted file mode 100644 index 197c09904d..0000000000 --- a/src/course-unit/clipboard/paste-component/components/PasteComponentButton.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import PropsTypes from 'prop-types'; -import { useParams } from 'react-router-dom'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Button } from '@openedx/paragon'; -import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons'; - -import messages from '../messages'; - -const PasteComponentButton = ({ handleCreateNewCourseXBlock }) => { - const intl = useIntl(); - const { blockId } = useParams(); - - const handlePasteXBlockComponent = () => { - handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId); - }; - - return ( - - ); -}; - -PasteComponentButton.propTypes = { - handleCreateNewCourseXBlock: PropsTypes.func.isRequired, -}; - -export default PasteComponentButton; diff --git a/src/course-unit/clipboard/paste-component/messages.js b/src/course-unit/clipboard/paste-component/messages.js deleted file mode 100644 index 1463a6746f..0000000000 --- a/src/course-unit/clipboard/paste-component/messages.js +++ /dev/null @@ -1,18 +0,0 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; - -const messages = defineMessages({ - pasteComponentButtonText: { - id: 'course-authoring.course-unit.paste-component.btn.text', - defaultMessage: 'Paste component', - }, - popoverContentText: { - id: 'course-authoring.course-unit.popover.content.text', - defaultMessage: 'From:', - }, - pasteComponentWhatsInClipboardText: { - id: 'course-authoring.course-unit.paste-component.whats-in-clipboard.text', - defaultMessage: "What's in my clipboard?", - }, -}); - -export default messages; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index facfd2130a..0efc89bbb9 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -14,7 +14,7 @@ import ConditionalSortableElement from '../../generic/drag-helper/ConditionalSor import { scrollToElement } from '../../course-outline/utils'; import { COURSE_BLOCK_NAMES } from '../../constants'; import { getCanEdit, getCourseId } from '../data/selectors'; -import { copyToClipboard } from '../data/thunk'; +import { copyToClipboard } from '../../generic/data/thunks'; import { COMPONENT_TYPES } from '../constants'; import XBlockContent from './xblock-content/XBlockContent'; import XBlockMessages from './xblock-messages/XBlockMessages'; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index b337c3a588..155e9d9878 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -11,8 +11,6 @@ export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/con export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`; export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`; export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`; -export const getClipboardUrl = () => `${getStudioBaseUrl()}/api/content-staging/v1/clipboard/`; - export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; /** @@ -84,29 +82,6 @@ export async function createCourseXblock({ return data; } -/** - * Retrieves user's clipboard. - * @returns {Promise} - A Promise that resolves clipboard data. - */ -export async function getClipboard() { - const { data } = await getAuthenticatedHttpClient() - .get(getClipboardUrl()); - - return camelCaseObject(data); -} - -/** - * Updates user's clipboard. - * @param {string} usageKey - The ID of the block. - * @returns {Promise} - A Promise that resolves clipboard data. - */ -export async function updateClipboard(usageKey) { - const { data } = await getAuthenticatedHttpClient() - .post(getClipboardUrl(), { usage_key: usageKey }); - - return camelCaseObject(data); -} - /** * Handles the visibility and data of a course unit, such as publishing, resetting to default values, * and toggling visibility to students. diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index d50eeab774..8006a9ce2b 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -1,5 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; -import { RequestStatus } from 'CourseAuthoring/data/constants'; + +import { RequestStatus } from '../../data/constants'; export const getCourseUnitData = (state) => state.courseUnit.unit; export const getCanEdit = (state) => state.courseUnit.canEdit; @@ -12,7 +13,6 @@ export const getCourseSectionVertical = (state) => state.courseUnit.courseSectio export const getCourseId = (state) => state.courseDetail.courseId; export const getSequenceId = (state) => state.courseUnit.sequenceId; export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren; -export const getClipboardData = (state) => state.courseUnit.clipboardData; const getLoadingStatuses = (state) => state.courseUnit.loadingStatus; export const getIsLoading = createSelector( [getLoadingStatuses], diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 5c76e4e41a..5a0613a94c 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -19,7 +19,6 @@ const slice = createSlice({ unit: {}, courseSectionVertical: {}, courseVerticalChildren: {}, - clipboardData: null, staticFileNotices: {}, }, reducers: { @@ -101,9 +100,6 @@ const slice = createSlice({ }), }; }, - updateClipboardData: (state, { payload }) => { - state.clipboardData = payload; - }, fetchStaticFileNoticesSuccess: (state, { payload }) => { state.staticFileNotices = payload; }, @@ -135,7 +131,6 @@ export const { updateCourseVerticalChildrenLoadingStatus, deleteXBlock, duplicateXBlock, - updateClipboardData, fetchStaticFileNoticesSuccess, reorderXBlockList, } = slice.actions; diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index b9aa48d931..cecc6bdd71 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -1,8 +1,5 @@ import { camelCaseObject } from '@edx/frontend-platform'; -import { logError } from '@edx/frontend-platform/logging'; -import { camelCaseObject } from '@edx/frontend-platform'; - import { hideProcessingNotification, showProcessingNotification, @@ -11,15 +8,13 @@ import { handleResponseErrors } from '../../generic/saving-error-alert'; import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { updateModel, updateModels } from '../../generic/model-store'; -import { CLIPBOARD_STATUS } from '../constants'; +import { updateClipboardData } from '../../generic/data/slice'; import { getCourseUnitData, editUnitDisplayName, getCourseSectionVerticalData, createCourseXblock, getCourseVerticalChildren, - updateClipboard, - getClipboard, handleCourseUnitVisibilityAndData, deleteUnitItem, duplicateUnitItem, @@ -37,7 +32,6 @@ import { updateLoadingCourseXblockStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, - updateClipboardData, updateQueryPendingStatus, deleteXBlock, duplicateXBlock, @@ -251,38 +245,6 @@ export function duplicateUnitItemQuery(itemId, xblockId) { }; } -export function copyToClipboard(usageKey) { - const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds - - return async (dispatch) => { - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(updateQueryPendingStatus(true)); - - try { - let clipboardData = await updateClipboard(usageKey); - - while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) { - // eslint-disable-next-line no-await-in-loop,no-promise-executor-return - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop - } - - if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) { - dispatch(updateClipboardData(clipboardData)); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } else { - throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`); - } - } catch (error) { - logError('Error copying to clipboard:', error); - handleResponseErrors(error, dispatch, updateSavingStatus); - } finally { - dispatch(hideProcessingNotification()); - } - }; -} - export function setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 7d5977a3c2..390f308823 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -27,7 +27,7 @@ import { } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; -import { useCopyToClipboard } from './clipboard'; +import { useCopyToClipboard } from '../generic/clipboard'; import { PUBLISH_TYPES } from './constants'; // eslint-disable-next-line import/prefer-default-export diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js index 024deac04a..7ed3e78c86 100644 --- a/src/course-unit/messages.js +++ b/src/course-unit/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.xblock.alert.unpublished-version.description', defaultMessage: 'Note: The last published version of this unit is live. By publishing changes you will change the student experience.', }, + pasteButtonText: { + id: 'course-authoring.course-unit.paste-component.btn.text', + defaultMessage: 'Paste component', + }, }); export default messages; diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx index a3e7e03afa..5f78ae7617 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx @@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Divider } from '../../../../generic/divider'; import { getCanEdit, getCourseUnitData } from '../../../data/selectors'; -import { copyToClipboard } from '../../../data/thunk'; +import { copyToClipboard } from '../../../../generic/data/thunks'; import messages from '../../messages'; const ActionButtons = ({ openDiscardModal, handlePublishing }) => { diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx index a91aba196b..2289968c18 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -8,14 +8,17 @@ import userEvent from '@testing-library/user-event'; import initializeStore from '../../../../store'; import { executeThunk } from '../../../../utils'; -import { getClipboardUrl, getCourseUnitApiUrl } from '../../../data/api'; -import { copyToClipboard, fetchCourseUnitQuery } from '../../../data/thunk'; -import { clipboardUnit, courseUnitIndexMock } from '../../../__mocks__'; +import { clipboardUnit } from '../../../../__mocks__'; +import { getCourseUnitApiUrl } from '../../../data/api'; +import { getClipboardUrl } from '../../../../generic/data/api'; +import { fetchCourseUnitQuery } from '../../../data/thunk'; +import { copyToClipboard } from '../../../../generic/data/thunks'; +import { courseUnitIndexMock } from '../../../__mocks__'; import messages from '../../messages'; import ActionButtons from './ActionButtons'; -jest.mock('../../../data/thunk', () => ({ - ...jest.requireActual('../../../data/thunk'), +jest.mock('../../../../generic/data/thunks', () => ({ + ...jest.requireActual('../../../../generic/data/thunks'), copyToClipboard: jest.fn().mockImplementation(() => () => {}), })); diff --git a/src/generic/broadcast-channel/hooks.js b/src/generic/broadcast-channel/hooks.js deleted file mode 100644 index 230c153d18..0000000000 --- a/src/generic/broadcast-channel/hooks.js +++ /dev/null @@ -1,46 +0,0 @@ -import { - useCallback, useEffect, useMemo, useRef, -} from 'react'; -import { BroadcastChannel } from 'broadcast-channel'; - -const channelInstances = {}; - -export const getSingletonChannel = (name) => { - if (!channelInstances[name]) { - channelInstances[name] = new BroadcastChannel(name); - } - return channelInstances[name]; -}; - -export const useBroadcastChannel = (channelName, onMessageReceived) => { - const channel = useMemo(() => getSingletonChannel(channelName), [channelName]); - const isSubscribed = useRef(false); - - useEffect(() => { - if (!isSubscribed.current || process.env.NODE_ENV !== 'development') { - // BroadcastChannel api from npm has minor difference from native BroadcastChannel - // Native BroadcastChannel passes event to onmessage callback and to - // access data we need to use `event.data`, but npm BroadcastChannel - // directly passes data as seen below - channel.onmessage = (data) => onMessageReceived(data); - } - return () => { - if (isSubscribed.current || process.env.NODE_ENV !== 'development') { - channel.close(); - isSubscribed.current = true; - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const postMessage = useCallback( - (message) => { - channel?.postMessage(message); - }, - [channel], - ); - - return { - postMessage, - }; -}; diff --git a/src/course-unit/clipboard/hooks/useClipboard.jsx b/src/generic/clipboard/hooks/useCopyToClipboard.js similarity index 74% rename from src/course-unit/clipboard/hooks/useClipboard.jsx rename to src/generic/clipboard/hooks/useCopyToClipboard.js index 0d0c6a82de..862788e0bb 100644 --- a/src/course-unit/clipboard/hooks/useClipboard.jsx +++ b/src/generic/clipboard/hooks/useCopyToClipboard.js @@ -1,15 +1,24 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; +import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants'; import { getClipboardData } from '../../data/selectors'; -import { CLIPBOARD_STATUS, NOT_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; -const useCopyToClipboard = (canEdit) => { +/** + * Custom React hook for managing clipboard functionality. + * + * @param {boolean} canEdit - Flag indicating whether the clipboard is editable. + * @returns {Object} - An object containing state variables and functions related to clipboard functionality. + * @property {boolean} showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible. + * @property {boolean} showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible. + * @property {Object} sharedClipboardData - The shared clipboard data object. + */ +const useCopyToClipboard = (canEdit = true) => { const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); const [showPasteUnit, setShowPasteUnit] = useState(false); const [showPasteXBlock, setShowPasteXBlock] = useState(false); - const clipboardData = useSelector(getClipboardData); const [sharedClipboardData, setSharedClipboardData] = useState({}); + const clipboardData = useSelector(getClipboardData); // Function to refresh the paste button's visibility const refreshPasteButton = (data) => { diff --git a/src/course-unit/clipboard/hooks/useClipboard.test.jsx b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx similarity index 85% rename from src/course-unit/clipboard/hooks/useClipboard.test.jsx rename to src/generic/clipboard/hooks/useCopyToClipboard.test.jsx index 049cd52477..2e57f409df 100644 --- a/src/course-unit/clipboard/hooks/useClipboard.test.jsx +++ b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx @@ -7,10 +7,10 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import initializeStore from '../../../store'; import { executeThunk } from '../../../utils'; -import { copyToClipboard } from '../../data/thunk'; +import { clipboardUnit, clipboardXBlock } from '../../../__mocks__'; +import { copyToClipboard } from '../../data/thunks'; import { getClipboardUrl } from '../../data/api'; -import { clipboardUnit, clipboardXBlock } from '../../__mocks__'; -import useClipboard from './useClipboard'; +import useCopyToClipboard from './useCopyToClipboard'; let axiosMock; let store; @@ -20,6 +20,7 @@ const clipboardBroadcastChannelMock = { postMessage: jest.fn(), close: jest.fn(), }; + global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); const wrapper = ({ children }) => ( @@ -46,7 +47,7 @@ describe('useCopyToClipboard', () => { }); it('initializes correctly', () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); expect(result.current.showPasteUnit).toBe(false); expect(result.current.showPasteXBlock).toBe(false); @@ -54,7 +55,7 @@ describe('useCopyToClipboard', () => { describe('clipboard data update effect', () => { it('returns falsy flags if canEdit = false', async () => { - const { result } = renderHook(() => useClipboard(false), { wrapper }); + const { result } = renderHook(() => useCopyToClipboard(false), { wrapper }); axiosMock .onPost(getClipboardUrl()) @@ -71,7 +72,7 @@ describe('useCopyToClipboard', () => { }); it('returns flag to display the Paste Unit button', async () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); axiosMock .onPost(getClipboardUrl()) @@ -88,7 +89,7 @@ describe('useCopyToClipboard', () => { }); it('returns flag to display the Paste XBlock button', async () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); axiosMock .onPost(getClipboardUrl()) @@ -107,7 +108,7 @@ describe('useCopyToClipboard', () => { describe('broadcast channel message handling', () => { it('updates states correctly on receiving a broadcast message', async () => { - const { result } = renderHook(() => useClipboard(true), { wrapper }); + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit }); expect(result.current.showPasteUnit).toBe(true); diff --git a/src/generic/clipboard/index.js b/src/generic/clipboard/index.js new file mode 100644 index 0000000000..2716b10c49 --- /dev/null +++ b/src/generic/clipboard/index.js @@ -0,0 +1,2 @@ +export { default as useCopyToClipboard } from './hooks/useCopyToClipboard'; +export { default as PasteComponent } from './paste-component'; diff --git a/src/course-unit/clipboard/paste-component/PasteComponent.scss b/src/generic/clipboard/paste-component/PasteComponent.scss similarity index 100% rename from src/course-unit/clipboard/paste-component/PasteComponent.scss rename to src/generic/clipboard/paste-component/PasteComponent.scss diff --git a/src/generic/clipboard/paste-component/components/PasteButton.jsx b/src/generic/clipboard/paste-component/components/PasteButton.jsx new file mode 100644 index 0000000000..a13dc28c6b --- /dev/null +++ b/src/generic/clipboard/paste-component/components/PasteButton.jsx @@ -0,0 +1,36 @@ +import PropsTypes from 'prop-types'; +import { useParams } from 'react-router-dom'; +import { Button } from '@openedx/paragon'; +import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons'; + +const PasteButton = ({ onClick, text, className }) => { + const { blockId } = useParams(); + + const handlePasteXBlockComponent = () => { + onClick({ stagedContent: 'clipboard', parentLocator: blockId }, null, blockId); + }; + + return ( + + ); +}; + +PasteButton.propTypes = { + onClick: PropsTypes.func.isRequired, + text: PropsTypes.string.isRequired, + className: PropsTypes.string, +}; + +PasteButton.defaultProps = { + className: undefined, +}; + +export default PasteButton; diff --git a/src/course-unit/clipboard/paste-component/components/PopoverContent.jsx b/src/generic/clipboard/paste-component/components/PopoverContent.jsx similarity index 100% rename from src/course-unit/clipboard/paste-component/components/PopoverContent.jsx rename to src/generic/clipboard/paste-component/components/PopoverContent.jsx diff --git a/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx similarity index 95% rename from src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx rename to src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx index 939dcfa2d5..d4e532b13c 100644 --- a/src/course-unit/clipboard/paste-component/components/WhatsInClipboard.jsx +++ b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx @@ -40,7 +40,7 @@ const WhatsInClipboard = ({ className="whats-in-clipboard-text m-0" onKeyDown={handleKeyDown} > - {intl.formatMessage(messages.pasteComponentWhatsInClipboardText)} + {intl.formatMessage(messages.pasteButtonWhatsInClipboardText)}

); diff --git a/src/course-unit/clipboard/paste-component/components/index.js b/src/generic/clipboard/paste-component/components/index.js similarity index 63% rename from src/course-unit/clipboard/paste-component/components/index.js rename to src/generic/clipboard/paste-component/components/index.js index 86980f4b9b..1336513b37 100644 --- a/src/course-unit/clipboard/paste-component/components/index.js +++ b/src/generic/clipboard/paste-component/components/index.js @@ -1,3 +1,3 @@ export { default as WhatsInClipboard } from './WhatsInClipboard'; -export { default as PasteComponentButton } from './PasteComponentButton'; +export { default as PasteButton } from './PasteButton'; export { default as PopoverContent } from './PopoverContent'; diff --git a/src/course-unit/clipboard/paste-component/constants.js b/src/generic/clipboard/paste-component/constants.js similarity index 100% rename from src/course-unit/clipboard/paste-component/constants.js rename to src/generic/clipboard/paste-component/constants.js diff --git a/src/course-unit/clipboard/paste-component/index.jsx b/src/generic/clipboard/paste-component/index.jsx similarity index 78% rename from src/course-unit/clipboard/paste-component/index.jsx rename to src/generic/clipboard/paste-component/index.jsx index ab140bf383..af4674952a 100644 --- a/src/course-unit/clipboard/paste-component/index.jsx +++ b/src/generic/clipboard/paste-component/index.jsx @@ -2,10 +2,12 @@ import { useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { OverlayTrigger, Popover } from '@openedx/paragon'; -import { PopoverContent, PasteComponentButton, WhatsInClipboard } from './components'; +import { PopoverContent, PasteButton, WhatsInClipboard } from './components'; import { clipboardPropsTypes, OVERLAY_TRIGGERS } from './constants'; -const PasteComponent = ({ handleCreateNewCourseXBlock, clipboardData }) => { +const PasteComponent = ({ + onClick, clipboardData, text, className, +}) => { const [showPopover, togglePopover] = useState(false); const popoverElementRef = useRef(null); @@ -31,9 +33,7 @@ const PasteComponent = ({ handleCreateNewCourseXBlock, clipboardData }) => { return ( <> - + { }; PasteComponent.propTypes = { - handleCreateNewCourseXBlock: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired, + text: PropTypes.string.isRequired, clipboardData: PropTypes.shape(clipboardPropsTypes), + blockType: PropTypes.string, + className: PropTypes.string, }; PasteComponent.defaultProps = { clipboardData: null, + blockType: null, + className: undefined, }; export default PasteComponent; diff --git a/src/generic/clipboard/paste-component/messages.js b/src/generic/clipboard/paste-component/messages.js new file mode 100644 index 0000000000..47c229b06d --- /dev/null +++ b/src/generic/clipboard/paste-component/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + popoverContentText: { + id: 'course-authoring.generic.paste-component.popover.content.text', + defaultMessage: 'From:', + }, + pasteButtonWhatsInClipboardText: { + id: 'course-authoring.generic.paste-component.paste-button.whats-in-clipboard.text', + defaultMessage: "What's in my clipboard?", + }, +}); + +export default messages; diff --git a/src/generic/data/api.js b/src/generic/data/api.js index 6cec7b9159..83fd561ff3 100644 --- a/src/generic/data/api.js +++ b/src/generic/data/api.js @@ -8,6 +8,7 @@ export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getCreateOrRerunCourseUrl = () => new URL('course/', getApiBaseUrl()).href; export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/course_rerun/${courseId}`, getApiBaseUrl()).href; export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href; +export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`; export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href; /** @@ -45,6 +46,29 @@ export async function createOrRerunCourse(courseData) { return camelCaseObject(data); } +/** + * Retrieves user's clipboard. + * @returns {Promise} - A Promise that resolves clipboard data. + */ +export async function getClipboard() { + const { data } = await getAuthenticatedHttpClient() + .get(getClipboardUrl()); + + return camelCaseObject(data); +} + +/** + * Updates user's clipboard. + * @param {string} usageKey - The ID of the block. + * @returns {Promise} - A Promise that resolves clipboard data. + */ +export async function updateClipboard(usageKey) { + const { data } = await getAuthenticatedHttpClient() + .post(getClipboardUrl(), { usage_key: usageKey }); + + return camelCaseObject(data); +} + /** * Gets the tags count of multiple content by id separated by commas or a pattern using a '*' wildcard. * @param {string} contentPattern diff --git a/src/generic/data/selectors.js b/src/generic/data/selectors.js index 461e09fe98..e111961b15 100644 --- a/src/generic/data/selectors.js +++ b/src/generic/data/selectors.js @@ -5,3 +5,4 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData; export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj; export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors; +export const getClipboardData = (state) => state.generic.clipboardData; diff --git a/src/generic/data/slice.js b/src/generic/data/slice.js index a25112704e..f53ddc610e 100644 --- a/src/generic/data/slice.js +++ b/src/generic/data/slice.js @@ -18,6 +18,7 @@ const slice = createSlice({ redirectUrlObj: {}, postErrors: {}, }, + clipboardData: null, }, reducers: { fetchOrganizations: (state, { payload }) => { @@ -41,6 +42,9 @@ const slice = createSlice({ updatePostErrors: (state, { payload }) => { state.createOrRerunCourse.postErrors = payload; }, + updateClipboardData: (state, { payload }) => { + state.clipboardData = payload; + }, }, }); @@ -52,6 +56,7 @@ export const { updateSavingStatus, updateCourseData, updateRedirectUrlObj, + updateClipboardData, } = slice.actions; export const { diff --git a/src/generic/data/thunks.js b/src/generic/data/thunks.js index 0008a187f4..bceb760389 100644 --- a/src/generic/data/thunks.js +++ b/src/generic/data/thunks.js @@ -1,5 +1,12 @@ +import { logError } from '@edx/frontend-platform/logging'; + +import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants'; +import { handleResponseErrors } from '../saving-error-alert'; +import { + hideProcessingNotification, + showProcessingNotification, +} from '../processing-notification/data/slice'; import { RequestStatus } from '../../data/constants'; -import { createOrRerunCourse, getOrganizations, getCourseRerun } from './api'; import { fetchOrganizations, updatePostErrors, @@ -7,7 +14,15 @@ import { updateRedirectUrlObj, updateCourseRerunData, updateSavingStatus, + updateClipboardData, } from './slice'; +import { + createOrRerunCourse, + getOrganizations, + getCourseRerun, + updateClipboard, + getClipboard, +} from './api'; export function fetchOrganizationsQuery() { return async (dispatch) => { @@ -49,3 +64,34 @@ 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); + handleResponseErrors(error, dispatch, updateSavingStatus); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} diff --git a/src/generic/saving-error-alert/SavingErrorAlert.jsx b/src/generic/saving-error-alert/SavingErrorAlert.jsx index db3431a0a9..9f1dbb798d 100644 --- a/src/generic/saving-error-alert/SavingErrorAlert.jsx +++ b/src/generic/saving-error-alert/SavingErrorAlert.jsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { Warning as WarningIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import { RequestStatus } from '../../data/constants'; +import { getSavingStatus as getGenericSavingStatus } from '../data/selectors'; import AlertMessage from '../alert-message'; import messages from './messages'; @@ -14,7 +16,8 @@ const SavingErrorAlert = ({ const intl = useIntl(); const [showAlert, setShowAlert] = useState(false); const [isOnline, setIsOnline] = useState(window.navigator.onLine); - const isQueryFailed = savingStatus === RequestStatus.FAILED; + const genericSavingStatus = useSelector(getGenericSavingStatus); + const isQueryFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; useEffect(() => { const handleOnlineStatus = () => setIsOnline(window.navigator.onLine); diff --git a/src/generic/styles.scss b/src/generic/styles.scss index e6b1f5de22..5fa8ed57e1 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -11,3 +11,4 @@ @import "./configure-modal/ConfigureModal"; @import "./drag-helper/ConditionalSortableElement"; @import "./divider/Divider"; +@import "./clipboard/paste-component/PasteComponent"; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 673177ddfa..c6e2d7962c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1149,8 +1149,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index a00ac08feb..98d259e4b0 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index 1c17b44175..5084c6f75b 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index e102967a36..8ec2c84a42 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/fa_IR.json b/src/i18n/messages/fa_IR.json index 8718b99f6e..d14a0aaa5b 100644 --- a/src/i18n/messages/fa_IR.json +++ b/src/i18n/messages/fa_IR.json @@ -172,8 +172,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index fbf533265c..bac78040a3 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index bd49fb0741..e1c85405c4 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index a00ac08feb..98d259e4b0 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index a00ac08feb..98d259e4b0 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index 8d00b24408..0a3d9f5b36 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 702e1473fb..a32a995601 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.upload-button.alt": "chapter-upload-button", "course-authoring.textbooks.form.delete-button.tooltip": "Delete", "course-authoring.textbooks.form.delete-button.alt": "chapter-delete-button", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index 676aec8fc5..19529be1a4 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index a00ac08feb..98d259e4b0 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index a00ac08feb..98d259e4b0 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files", diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index a00ac08feb..98d259e4b0 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -1150,8 +1150,6 @@ "course-authoring.textbooks.form.delete-modal.description": "Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed.", "course-authoring.course-unit.xblock.iframe.error.text": "Unit iframe failed to load. Server possibly returned 4xx or 5xx response.", "course-authoring.course-unit.paste-component.btn.text": "Paste component", - "course-authoring.course-unit.popover.content.text": "From:", - "course-authoring.course-unit.paste-component.whats-in-clipboard.text": "What's in my clipboard?", "course-authoring.course-unit.paste-notification.has-conflicting-errors.title": "Files need to be updated manually.", "course-authoring.course-unit.paste-notification.has-conflicting-errors.description": "The following files must be updated manually for components to work as intended:", "course-authoring.course-unit.paste-notification.has-conflicting-errors.button.text": "Upload files",