diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index cef569217e..b713c7850f 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -17,7 +17,6 @@ import { getCourseBlockApiUrl, getCourseItemApiUrl, getXBlockBaseApiUrl, - getClipboardUrl, } from './data/api'; import { RequestStatus } from '../data/constants'; import { @@ -40,12 +39,13 @@ 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 '../generic/clipboard/paste-button/messages'; import subsectionMessages from './subsection-card/messages'; import pageAlertMessages from './page-alerts/messages'; import messages from './messages'; @@ -1840,7 +1840,7 @@ describe('', () => { await act(async () => fireEvent.click(copyButton)); // check that initialUserClipboard state is updated - expect(store.getState().courseOutline.initialUserClipboard).toEqual(clipboardUnit); + expect(store.getState().generic.clipboardData).toEqual(clipboardUnit); [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); // find clipboard content label @@ -1851,9 +1851,8 @@ describe('', () => { // find clipboard content popover link const popoverContent = queryByTestId('popover-content'); - const apiBaseUrl = getConfig().STUDIO_BASE_URL; expect(popoverContent.tagName).toBe('A'); - expect(popoverContent).toHaveAttribute('href', apiBaseUrl + unit.studioUrl); + 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 3c2e038088..6a699a850c 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -28,7 +28,6 @@ export const getCourseReindexApiUrl = (reindexLink) => `${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 @@ -434,20 +433,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 fcf1b4881f..0616ee4b97 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -8,5 +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; export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 0e8a3d342a..fa7411f3b7 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, }, reducers: { @@ -51,7 +45,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 }) => { @@ -78,9 +71,6 @@ const slice = createSlice({ ...payload, }; }, - updateClipboardContent: (state, { payload }) => { - state.initialUserClipboard = payload; - }, updateCourseActions: (state, { payload }) => { state.actions = { ...state.actions, @@ -217,7 +207,6 @@ 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 af6a78ba0f..bd5f4fd0f8 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -1,5 +1,6 @@ import { RequestStatus } from '../../data/constants'; -import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants'; +import { updateClipboardData } from '../../generic/data/slice'; +import { NOTIFICATION_MESSAGES } from '../../constants'; import { COURSE_BLOCK_NAMES } from '../constants'; import { hideProcessingNotification, @@ -28,7 +29,6 @@ import { setSectionOrderList, setVideoSharingOption, setCourseItemOrderList, - copyBlockToClipboard, pasteBlock, dismissNotification, } from './api'; @@ -52,7 +52,6 @@ import { reorderSectionList, reorderSubsectionList, reorderUnitList, - updateClipboardContent, } from './slice'; export function fetchCourseOutlineIndexQuery(courseId) { @@ -71,6 +70,7 @@ export function fetchCourseOutlineIndexQuery(courseId) { }, } = outlineIndex; dispatch(fetchOutlineIndexSuccess(outlineIndex)); + dispatch(updateClipboardData(outlineIndex.initialUserClipboard)); dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging, @@ -581,29 +581,6 @@ export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, rest }; } -export function setClipboardContent(usageKey) { - 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 === CLIPBOARD_STATUS.ready) { - dispatch(updateClipboardContent(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 d7f686adea..2376770515 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -4,6 +4,8 @@ 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 { @@ -48,7 +50,6 @@ import { setVideoSharingOptionQuery, setSubsectionOrderListQuery, setUnitOrderListQuery, - setClipboardContent, pasteClipboardContent, dismissNotificationQuery, } from './data/thunk'; @@ -79,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); @@ -90,8 +92,10 @@ const useCourseOutline = ({ courseId }) => { const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; + const handleCopyToClipboardClick = (usageKey) => { - dispatch(setClipboardContent(usageKey)); + dispatch(copyToClipboard(usageKey)); }; const handlePasteClipboardClick = (parentLocator, sectionId) => { @@ -294,7 +298,7 @@ const useCourseOutline = ({ courseId }) => { isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal, - isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, + isInternetConnectionAlertFailed: isSavingStatusFailed, handleInternetConnectionFailed, handleOpenHighlightsModal, isHighlightsModalOpen, @@ -327,6 +331,7 @@ const useCourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, handleDismissNotification, advanceSettingsUrl, + genericSavingStatus, }; }; diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 3d401b54ad..5767f88515 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -1,18 +1,17 @@ import { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useSearchParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, useToggle } from '@openedx/paragon'; import { Add as IconAdd } from '@openedx/paragon/icons'; import classNames from 'classnames'; -import { getInitialUserClipboard } from '../data/selectors'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; import ConditionalSortableElement from '../../generic/drag-helper/ConditionalSortableElement'; -import { useCopyToClipboard, PasteButton } from '../../generic/clipboard'; +import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; @@ -44,8 +43,7 @@ const SubsectionCard = ({ const isScrolledToElement = locatorId === subsection.id; const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'subsection'; - const initialUserClipboard = useSelector(getInitialUserClipboard); - const { sharedClipboardData, showPasteUnit } = useCopyToClipboard(initialUserClipboard); + const { sharedClipboardData, showPasteUnit } = useCopyToClipboard(); const { id, @@ -196,7 +194,7 @@ const SubsectionCard = ({ {intl.formatMessage(messages.newUnitButton)} {enableCopyPasteUnits && showPasteUnit && ( - { handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} /> {showPasteXBlock && canPasteComponent && ( - )} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index db47905e20..a9b94099d3 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, @@ -40,7 +39,7 @@ import { } from '../__mocks__'; import { executeThunk } from '../utils'; import deleteModalMessages from '../generic/delete-modal/messages'; -import pasteButtonMessages from '../generic/clipboard/paste-button/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'; @@ -54,6 +53,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; @@ -930,7 +930,7 @@ describe('', () => { await waitFor(() => { expect(queryByText(sidebarMessages.actionButtonCopyUnitTitle.defaultMessage)).toBeNull(); - expect(queryByRole('button', { name: messages.pasteComponentButtonText.defaultMessage })).toBeNull(); + expect(queryByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeNull(); }); axiosMock @@ -963,10 +963,10 @@ describe('', () => { }); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - expect(getByRole('button', { name: messages.pasteComponentButtonText.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); const whatsInClipboardText = getByText( - pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage, + pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, ); userEvent.hover(whatsInClipboardText); @@ -1012,7 +1012,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(copyToClipboard(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: messages.pasteComponentButtonText.defaultMessage })); + userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); await waitFor(() => { expect(getAllByTestId('course-xblock')).toHaveLength(2); @@ -1055,7 +1055,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(copyToClipboard(blockId), store.dispatch); - expect(getByRole('button', { name: messages.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 () => { @@ -1321,10 +1321,10 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); expect(queryByRole('button', { - name: messages.pasteComponentButtonText.defaultMessage, + name: messages.pasteButtonText.defaultMessage, })).not.toBeInTheDocument(); expect(queryByText( - pasteButtonMessages.pasteButtonWhatsInClipboardText.defaultMessage, + pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, )).not.toBeInTheDocument(); }); }); diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 2503dfcbbd..77419b418a 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 ad27253c9f..cecc6bdd71 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -1,4 +1,3 @@ -import { logError } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform'; import { @@ -9,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, @@ -35,7 +32,6 @@ import { updateLoadingCourseXblockStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, - updateClipboardData, updateQueryPendingStatus, deleteXBlock, duplicateXBlock, @@ -249,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 7ed9e323d7..390f308823 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -24,7 +24,6 @@ import { getSequenceStatus, getStaticFileNotices, getCanEdit, - getClipboardData, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; @@ -47,8 +46,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen); const canEdit = useSelector(getCanEdit); const { currentlyVisibleToStudents } = courseUnit; - const clipboardData = useSelector(getClipboardData); - const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(clipboardData, canEdit); + const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); const { canPasteComponent } = courseVerticalChildren; const unitTitle = courseUnit.metadata?.displayName || ''; diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js index 8525886ca5..7ed3e78c86 100644 --- a/src/course-unit/messages.js +++ b/src/course-unit/messages.js @@ -9,7 +9,7 @@ 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.', }, - pasteComponentButtonText: { + pasteButtonText: { id: 'course-authoring.course-unit.paste-component.btn.text', defaultMessage: 'Paste component', }, 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 290a1f2c53..2289968c18 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.test.jsx @@ -9,14 +9,16 @@ import userEvent from '@testing-library/user-event'; import initializeStore from '../../../../store'; import { executeThunk } from '../../../../utils'; import { clipboardUnit } from '../../../../__mocks__'; -import { getClipboardUrl, getCourseUnitApiUrl } from '../../../data/api'; -import { copyToClipboard, fetchCourseUnitQuery } from '../../../data/thunk'; +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/clipboard/hooks/useCopyToClipboard.js b/src/generic/clipboard/hooks/useCopyToClipboard.js index 83d7f50709..862788e0bb 100644 --- a/src/generic/clipboard/hooks/useCopyToClipboard.js +++ b/src/generic/clipboard/hooks/useCopyToClipboard.js @@ -1,22 +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'; /** * Custom React hook for managing clipboard functionality. * - * @param {Object} clipboardData - The clipboard data object. * @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 = (clipboardData, canEdit = true) => { +const useCopyToClipboard = (canEdit = true) => { const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); const [showPasteUnit, setShowPasteUnit] = useState(false); const [showPasteXBlock, setShowPasteXBlock] = useState(false); const [sharedClipboardData, setSharedClipboardData] = useState({}); + const clipboardData = useSelector(getClipboardData); // Function to refresh the paste button's visibility const refreshPasteButton = (data) => { diff --git a/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx index 8157515648..2e57f409df 100644 --- a/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx +++ b/src/generic/clipboard/hooks/useCopyToClipboard.test.jsx @@ -1,14 +1,21 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks'; import { Provider } from 'react-redux'; import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import initializeStore from '../../../store'; +import { executeThunk } from '../../../utils'; import { clipboardUnit, clipboardXBlock } from '../../../__mocks__'; +import { copyToClipboard } from '../../data/thunks'; +import { getClipboardUrl } from '../../data/api'; import useCopyToClipboard from './useCopyToClipboard'; +let axiosMock; let store; - +const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc'; +const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4'; const clipboardBroadcastChannelMock = { postMessage: jest.fn(), close: jest.fn(), @@ -36,54 +43,80 @@ describe('useCopyToClipboard', () => { }); store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); it('initializes correctly', () => { - const { result } = renderHook(() => useCopyToClipboard(clipboardUnit, true), { wrapper }); + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); - expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteUnit).toBe(false); expect(result.current.showPasteXBlock).toBe(false); }); - it('should update state and broadcast channel on clipboardData change', () => { - const { result, rerender } = renderHook(({ clipboardData }) => useCopyToClipboard(clipboardData, true), { - initialProps: { clipboardData: clipboardUnit }, + describe('clipboard data update effect', () => { + it('returns falsy flags if canEdit = false', async () => { + const { result } = renderHook(() => useCopyToClipboard(false), { wrapper }); + + axiosMock + .onPost(getClipboardUrl()) + .reply(200, clipboardUnit); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); + + await act(async () => { + await executeThunk(copyToClipboard(unitId), store.dispatch); + }); + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(false); }); - expect(result.current.showPasteUnit).toBe(true); - expect(result.current.showPasteXBlock).toBe(false); - expect(result.current.sharedClipboardData).toEqual(clipboardUnit); - - rerender({ clipboardData: clipboardXBlock }); - - expect(result.current.showPasteUnit).toBe(false); - expect(result.current.showPasteXBlock).toBe(true); - expect(result.current.sharedClipboardData).toEqual(clipboardXBlock); - }); - - it('should update state and broadcast channel when canEdit is false', () => { - const { result } = renderHook(({ clipboardData }) => useCopyToClipboard(clipboardData, false), { - initialProps: { clipboardData: clipboardUnit }, + it('returns flag to display the Paste Unit button', async () => { + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); + + axiosMock + .onPost(getClipboardUrl()) + .reply(200, clipboardUnit); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); + + await act(async () => { + await executeThunk(copyToClipboard(unitId), store.dispatch); + }); + expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteXBlock).toBe(false); }); - expect(result.current.showPasteUnit).toBe(false); - expect(result.current.showPasteXBlock).toBe(false); - expect(result.current.sharedClipboardData).toEqual({}); - }); - - it('updates states correctly on receiving a broadcast message', async () => { - const { result } = renderHook(({ clipboardData }) => useCopyToClipboard(clipboardData, true), { - initialProps: { clipboardData: clipboardUnit }, + it('returns flag to display the Paste XBlock button', async () => { + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); + + axiosMock + .onPost(getClipboardUrl()) + .reply(200, clipboardXBlock); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardXBlock); + + await act(async () => { + await executeThunk(copyToClipboard(xblockId), store.dispatch); + }); + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(true); }); + }); - clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit }); - - expect(result.current.showPasteUnit).toBe(true); - expect(result.current.showPasteXBlock).toBe(false); + describe('broadcast channel message handling', () => { + it('updates states correctly on receiving a broadcast message', async () => { + const { result } = renderHook(() => useCopyToClipboard(true), { wrapper }); + clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit }); - clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock }); + expect(result.current.showPasteUnit).toBe(true); + expect(result.current.showPasteXBlock).toBe(false); - expect(result.current.showPasteUnit).toBe(false); - expect(result.current.showPasteXBlock).toBe(true); + clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock }); + expect(result.current.showPasteUnit).toBe(false); + expect(result.current.showPasteXBlock).toBe(true); + }); }); }); diff --git a/src/generic/clipboard/index.js b/src/generic/clipboard/index.js index f5b9a7e5a8..2716b10c49 100644 --- a/src/generic/clipboard/index.js +++ b/src/generic/clipboard/index.js @@ -1,2 +1,2 @@ export { default as useCopyToClipboard } from './hooks/useCopyToClipboard'; -export { default as PasteButton } from './paste-button'; +export { default as PasteComponent } from './paste-component'; diff --git a/src/generic/clipboard/paste-button/PasteButton.scss b/src/generic/clipboard/paste-component/PasteComponent.scss similarity index 100% rename from src/generic/clipboard/paste-button/PasteButton.scss rename to src/generic/clipboard/paste-component/PasteComponent.scss diff --git a/src/generic/clipboard/paste-button/components/PasteButtonComponent.jsx b/src/generic/clipboard/paste-component/components/PasteButton.jsx similarity index 80% rename from src/generic/clipboard/paste-button/components/PasteButtonComponent.jsx rename to src/generic/clipboard/paste-component/components/PasteButton.jsx index 0d1e68f93b..a13dc28c6b 100644 --- a/src/generic/clipboard/paste-button/components/PasteButtonComponent.jsx +++ b/src/generic/clipboard/paste-component/components/PasteButton.jsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom'; import { Button } from '@openedx/paragon'; import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons'; -const PasteButtonComponent = ({ onClick, text, className }) => { +const PasteButton = ({ onClick, text, className }) => { const { blockId } = useParams(); const handlePasteXBlockComponent = () => { @@ -23,14 +23,14 @@ const PasteButtonComponent = ({ onClick, text, className }) => { ); }; -PasteButtonComponent.propTypes = { +PasteButton.propTypes = { onClick: PropsTypes.func.isRequired, text: PropsTypes.string.isRequired, className: PropsTypes.string, }; -PasteButtonComponent.defaultProps = { +PasteButton.defaultProps = { className: undefined, }; -export default PasteButtonComponent; +export default PasteButton; diff --git a/src/generic/clipboard/paste-button/components/PopoverContent.jsx b/src/generic/clipboard/paste-component/components/PopoverContent.jsx similarity index 100% rename from src/generic/clipboard/paste-button/components/PopoverContent.jsx rename to src/generic/clipboard/paste-component/components/PopoverContent.jsx diff --git a/src/generic/clipboard/paste-button/components/WhatsInClipboard.jsx b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx similarity index 100% rename from src/generic/clipboard/paste-button/components/WhatsInClipboard.jsx rename to src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx diff --git a/src/generic/clipboard/paste-button/components/index.js b/src/generic/clipboard/paste-component/components/index.js similarity index 63% rename from src/generic/clipboard/paste-button/components/index.js rename to src/generic/clipboard/paste-component/components/index.js index 98b836f352..1336513b37 100644 --- a/src/generic/clipboard/paste-button/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 PasteButtonComponent } from './PasteButtonComponent'; +export { default as PasteButton } from './PasteButton'; export { default as PopoverContent } from './PopoverContent'; diff --git a/src/generic/clipboard/paste-button/constants.js b/src/generic/clipboard/paste-component/constants.js similarity index 100% rename from src/generic/clipboard/paste-button/constants.js rename to src/generic/clipboard/paste-component/constants.js diff --git a/src/generic/clipboard/paste-button/index.jsx b/src/generic/clipboard/paste-component/index.jsx similarity index 85% rename from src/generic/clipboard/paste-button/index.jsx rename to src/generic/clipboard/paste-component/index.jsx index e9eebefbee..af4674952a 100644 --- a/src/generic/clipboard/paste-button/index.jsx +++ b/src/generic/clipboard/paste-component/index.jsx @@ -2,10 +2,10 @@ import { useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { OverlayTrigger, Popover } from '@openedx/paragon'; -import { PopoverContent, PasteButtonComponent, WhatsInClipboard } from './components'; +import { PopoverContent, PasteButton, WhatsInClipboard } from './components'; import { clipboardPropsTypes, OVERLAY_TRIGGERS } from './constants'; -const PasteButton = ({ +const PasteComponent = ({ onClick, clipboardData, text, className, }) => { const [showPopover, togglePopover] = useState(false); @@ -33,7 +33,7 @@ const PasteButton = ({ return ( <> - + 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/`; /** * Get's organizations data. Returns list of organization names. @@ -43,3 +44,26 @@ 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); +} 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..bcb26e3c8e 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 } 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(getSavingStatus); + 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 ec5890b4b9..5e38bfb2fd 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -10,4 +10,4 @@ @import "./configure-modal/ConfigureModal"; @import "./drag-helper/ConditionalSortableElement"; @import "./divider/Divider"; -@import "./clipboard/paste-button/PasteButton"; +@import "./clipboard/paste-component/PasteComponent";