diff --git a/src/constants.js b/src/constants.js index 163a16ef84..bf4696d734 100644 --- a/src/constants.js +++ b/src/constants.js @@ -76,3 +76,7 @@ export const REGEX_RULES = { specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/, noSpaceRule: /^\S*$/, }; + +export const IFRAME_FEATURE_POLICY = ( + 'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *' +); diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 68aa3a3d46..74b2a20807 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -50,6 +50,7 @@ const CourseUnit = ({ courseId }) => { isTitleEditFormOpen, staticFileNotices, currentlyVisibleToStudents, + unitXBlockActions, sharedClipboardData, showPasteXBlock, showPasteUnit, @@ -58,6 +59,7 @@ const CourseUnit = ({ courseId }) => { handleTitleEdit, handleCreateNewCourseXBlock, handleConfigureSubmit, + courseVerticalChildren, canPasteComponent, isMoveModalOpen, openMoveModal, @@ -176,7 +178,13 @@ const CourseUnit = ({ courseId }) => { courseId={courseId} /> )} - + clipboardBroadcastChannelMock); +/** + * Simulates receiving a post message event for testing purposes. + * This can be used to mimic events like deletion or other actions + * sent from Backbone or other sources via postMessage. + * + * @param {string} type - The type of the message event (e.g., 'deleteXBlock'). + * @param {Object} payload - The payload data for the message event. + */ +function simulatePostMessageEvent(type, payload) { + const messageEvent = new MessageEvent('message', { + data: { type, payload }, + }); + + window.dispatchEvent(messageEvent); +} + const RootWrapper = () => ( @@ -166,6 +187,248 @@ describe('', () => { }); }); + it('renders the course unit iframe with correct attributes', async () => { + const { getByTitle } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`); + expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); + expect(iframe).toHaveAttribute('style', 'width: 100%; height: 220px;'); + expect(iframe).toHaveAttribute('scrolling', 'no'); + expect(iframe).toHaveAttribute('referrerpolicy', 'origin'); + expect(iframe).toHaveAttribute('loading', 'lazy'); + expect(iframe).toHaveAttribute('frameborder', '0'); + }); + }); + + it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { + const { + getByTitle, getByText, queryByRole, getAllByRole, getByRole, + } = render(); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.deleteXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + expect(getByText(/Delete this component?/i)).toBeInTheDocument(); + expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); + + expect(getByRole('dialog')).toBeInTheDocument(); + + // Find the Cancel and Delete buttons within the iframe by their specific classes + const cancelButton = getAllByRole('button', { name: /Cancel/i }) + .find(({ classList }) => classList.contains('btn-tertiary')); + const deleteButton = getAllByRole('button', { name: /Delete/i }) + .find(({ classList }) => classList.contains('btn-primary')); + + userEvent.click(cancelButton); + waitFor(() => expect(getByRole('dialog')).not.toBeInTheDocument()); + + simulatePostMessageEvent(messageTypes.deleteXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + expect(getByRole('dialog')).toBeInTheDocument(); + userEvent.click(deleteButton); + waitFor(() => expect(getByRole('dialog')).not.toBeInTheDocument()); + }); + + axiosMock + .onPost(getXBlockBaseApiUrl(blockId), { + publish: PUBLISH_TYPES.makePublic, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + visibility_state: UNIT_VISIBILITY_STATES.live, + has_changes: false, + published_by: userName, + }); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + // check if the sidebar status is Published and Live + expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseUnitIndexMock.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(getByText(unitDisplayName)).toBeInTheDocument(); + }); + + axiosMock + .onDelete(getXBlockBaseApiUrl(courseVerticalChildrenMock.children[0].block_id)) + .replyOnce(200, { dummy: 'value' }); + await executeThunk(deleteUnitItemQuery(courseId, blockId), store.dispatch); + + const updatedCourseVerticalChildren = courseVerticalChildrenMock.children.filter( + child => child.block_id !== courseVerticalChildrenMock.children[0].block_id, + ); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + children: updatedCourseVerticalChildren, + isPublished: false, + canPasteComponent: true, + }); + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + // after removing the xblock, the sidebar status changes to Draft (unpublished changes) + expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishInfoDraftSaved.defaultMessage + .replace('{editedOn}', courseUnitIndexMock.edited_on) + .replace('{editedBy}', courseUnitIndexMock.edited_by), + )).toBeInTheDocument(); + expect(getByText( + sidebarMessages.releaseInfoWithSection.defaultMessage + .replace('{sectionName}', courseUnitIndexMock.release_date_from), + )).toBeInTheDocument(); + }); + }); + + it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { + const { + getByTitle, getByRole, getByText, queryByRole, + } = render(); + + simulatePostMessageEvent(messageTypes.duplicateXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + axiosMock + .onPost(postXBlockBaseApiUrl({ + parent_locator: blockId, + duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, + })) + .replyOnce(200, { locator: '1234567890' }); + + const updatedCourseVerticalChildren = [ + ...courseVerticalChildrenMock.children, + { + ...courseVerticalChildrenMock.children[0], + name: 'New Cloned XBlock', + }, + ]; + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + children: updatedCourseVerticalChildren, + }); + + await waitFor(() => { + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.duplicateXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + axiosMock + .onPost(getXBlockBaseApiUrl(blockId), { + publish: PUBLISH_TYPES.makePublic, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + visibility_state: UNIT_VISIBILITY_STATES.live, + has_changes: false, + published_by: userName, + }); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + // check if the sidebar status is Published and Live + expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseUnitIndexMock.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(getByText(unitDisplayName)).toBeInTheDocument(); + }); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + + // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) + expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(getByText( + sidebarMessages.publishInfoDraftSaved.defaultMessage + .replace('{editedOn}', courseUnitIndexMock.edited_on) + .replace('{editedBy}', courseUnitIndexMock.edited_by), + )).toBeInTheDocument(); + expect(getByText( + sidebarMessages.releaseInfoWithSection.defaultMessage + .replace('{sectionName}', courseUnitIndexMock.release_date_from), + )).toBeInTheDocument(); + }); + }); + it('handles CourseUnit header action buttons', async () => { const { open } = window; window.open = jest.fn(); @@ -551,46 +814,6 @@ describe('', () => { expect(alert).toBeUndefined(); }); }); - // axiosMock - // .onPost(postXBlockBaseApiUrl({ - // parent_locator: blockId, - // duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, - // })) - // .replyOnce(200, { locator: '1234567890' }); - - // axiosMock - // .onGet(getCourseVerticalChildrenApiUrl(blockId)) - // .reply(200, { - // ...courseVerticalChildrenMock, - // children: [ - // ...courseVerticalChildrenMock.children, - // { - // name: 'New Cloned XBlock', - // block_id: '1234567890', - // block_type: 'drag-and-drop-v2', - // user_partition_info: {}, - // }, - // ], - // }); - - // const { - // getByText, - // getAllByLabelText, - // getAllByTestId, - // } = render(); - - // await waitFor(() => { - // expect(getByText(unitDisplayName)).toBeInTheDocument(); - // const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); - // userEvent.click(xblockActionBtn); - - // const duplicateBtn = getByText(courseXBlockMessages.blockLabelButtonDuplicate.defaultMessage); - // userEvent.click(duplicateBtn); - - // expect(getAllByTestId('course-xblock')).toHaveLength(3); - // expect(getByText('New Cloned XBlock')).toBeInTheDocument(); - // }); - // }); it('should toggle visibility from sidebar and update course unit state accordingly', async () => { const { getByRole, getByTestId } = render(); @@ -865,6 +1088,77 @@ describe('', () => { expect(queryByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })).toBeInTheDocument(); }); + it('should increase the number of course XBlocks after copying and pasting a block', async () => { + const { getByRole, getByTitle } = render(); + + simulatePostMessageEvent(messageTypes.copyXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + user_clipboard: clipboardXBlock, + }); + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + enable_copy_paste_units: true, + }); + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length), + ); + + simulatePostMessageEvent(messageTypes.copyXBlock, { + id: courseVerticalChildrenMock.children[0].block_id, + }); + }); + + const updatedCourseVerticalChildren = [ + ...courseVerticalChildrenMock.children, + { + name: 'Copy XBlock', + block_id: '1234567890', + block_type: 'drag-and-drop-v2', + user_partition_info: { + selectable_partitions: [], + selected_partition_index: -1, + selected_groups_label: '', + }, + }, + ]; + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + children: updatedCourseVerticalChildren, + }); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + await waitFor(() => { + const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', updatedCourseVerticalChildren.length), + ); + }); + }); + it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { const { getAllByTestId, getByRole, @@ -1123,54 +1417,4 @@ describe('', () => { )).not.toBeInTheDocument(); }); }); - // it('checks xblock list is restored to original order when API call fails', async () => { - // const { findAllByRole } = render(); - - // const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - // const draggableButton = xBlocksDraggers[1]; - - // axiosMock - // .onPut(getXBlockBaseApiUrl(blockId)) - // .reply(500, { dummy: 'value' }); - - // const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; - - // fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); - - // await waitFor(async () => { - // fireEvent.keyDown(draggableButton, { code: 'Space' }); - - // const saveStatus = store.getState().courseUnit.savingStatus; - // expect(saveStatus).toEqual(RequestStatus.FAILED); - // }); - - // const xBlock1New = store.getState().courseUnit.courseVerticalChildren.children[0].id; - // expect(xBlock1).toBe(xBlock1New); - // }); - - // it('check that new xblock list is saved when dragged', async () => { - // const { findAllByRole } = render(); - - // const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - // const draggableButton = xBlocksDraggers[1]; - - // axiosMock - // .onPut(getXBlockBaseApiUrl(blockId)) - // .reply(200, { dummy: 'value' }); - - // const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id; - - // fireEvent.keyDown(draggableButton, { key: 'ArrowUp' }); - - // await waitFor(async () => { - // fireEvent.keyDown(draggableButton, { code: 'Space' }); - - // const saveStatus = store.getState().courseUnit.savingStatus; - // expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL); - // }); - - // const xBlock2 = store.getState().courseUnit.courseVerticalChildren.children[1].id; - // expect(xBlock1).toBe(xBlock2); - // }); - // }); }); diff --git a/src/course-unit/__mocks__/clipboardXBlock.js b/src/course-unit/__mocks__/clipboardXBlock.js new file mode 100644 index 0000000000..ecaf0b50b1 --- /dev/null +++ b/src/course-unit/__mocks__/clipboardXBlock.js @@ -0,0 +1,16 @@ +export default { + content: { + id: 69, + userId: 3, + created: '2024-01-16T13:33:21.314439Z', + purpose: 'clipboard', + status: 'ready', + blockType: 'html', + blockTypeDisplay: 'Text', + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx', + displayName: 'Blank HTML Page', + }, + sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1', + sourceContextTitle: 'Demonstration Course', + sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1', +}; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index ebadb310b4..f95e438d0a 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -52,8 +52,10 @@ export const messageTypes = { videoFullScreen: 'plugin.videoFullScreen', refreshXBlock: 'refreshXBlock', showMoveXBlockModal: 'showMoveXBlockModal', + copyXBlock: 'copyXBlock', + manageXBlockAccess: 'manageXBlockAccess', + deleteXBlock: 'deleteXBlock', + duplicateXBlock: 'duplicateXBlock', + refreshPositions: 'refreshPositions', + newXBlockEditor: 'newXBlockEditor', }; - -export const IFRAME_FEATURE_POLICY = ( - 'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *' -); diff --git a/src/course-unit/context/hooks.tsx b/src/course-unit/context/hooks.tsx index fcb13041c9..9760c07afc 100644 --- a/src/course-unit/context/hooks.tsx +++ b/src/course-unit/context/hooks.tsx @@ -2,10 +2,11 @@ import { useContext } from 'react'; import { IframeContext, IframeContextType } from './iFrameContext'; +// eslint-disable-next-line import/prefer-default-export export const useIframe = (): IframeContextType => { - const context = useContext(IframeContext); - if (!context) { - throw new Error('useIframe must be used within an IframeProvider'); - } - return context; + const context = useContext(IframeContext); + if (!context) { + throw new Error('useIframe must be used within an IframeProvider'); + } + return context; }; diff --git a/src/course-unit/context/iFrameContext.tsx b/src/course-unit/context/iFrameContext.tsx index 17586a3de8..712dbf09cf 100644 --- a/src/course-unit/context/iFrameContext.tsx +++ b/src/course-unit/context/iFrameContext.tsx @@ -16,14 +16,17 @@ export const IframeProvider: React.FC = ({ children }) => { try { iframeWindow.postMessage({ type: messageType, payload }, '*'); } catch (error) { + // eslint-disable-next-line no-console console.error('Failed to send message to iframe:', error); } } else { + // eslint-disable-next-line no-console console.warn('Iframe is not accessible or loaded yet.'); } }; return ( + // eslint-disable-next-line react/jsx-no-constructed-context-values {children} diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index cce2d89e27..4a6b7b1693 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -134,6 +134,22 @@ export async function deleteUnitItem(itemId) { return data; } +/** + * Duplicate a unit item. + * @param {string} itemId + * @param {string} XBlockId + * @returns {Promise} + */ +export async function duplicateUnitItem(itemId, XBlockId) { + const { data } = await getAuthenticatedHttpClient() + .post(postXBlockBaseApiUrl(), { + parent_locator: itemId, + duplicate_source_locator: XBlockId, + }); + + return data; +} + /** * Get an object containing course outline data. * @param {string} courseId - The identifier of the course. diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index fec0ba7dc2..1755d0960f 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -28,7 +28,7 @@ const slice = createSlice({ title: '', sourceLocator: '', targetParentLocator: '', - } + }, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index ac7e1a62d5..a21a0c6f21 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -17,6 +17,7 @@ import { getCourseVerticalChildren, handleCourseUnitVisibilityAndData, deleteUnitItem, + duplicateUnitItem, getCourseOutlineInfo, patchUnitItem, } from './api'; @@ -206,7 +207,6 @@ export function fetchCourseVerticalChildrenData(itemId) { }; } -// TODO: use for xblock delete functionality export function deleteUnitItemQuery(itemId, xblockId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -227,6 +227,24 @@ export function deleteUnitItemQuery(itemId, xblockId) { }; } +export function duplicateUnitItemQuery(itemId, xblockId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); + + try { + await duplicateUnitItem(itemId, xblockId); + const courseUnit = await getCourseUnitData(itemId); + dispatch(fetchCourseItemSuccess(courseUnit)); + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(hideProcessingNotification()); + handleResponseErrors(error, dispatch, updateSavingStatus); + } + }; +} + export function getCourseOutlineInfoQuery(courseId) { return async (dispatch) => { dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS })); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index bb78de3b2c..bf8b657ca2 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; @@ -13,6 +13,7 @@ import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, deleteUnitItemQuery, + duplicateUnitItemQuery, editCourseUnitVisibilityAndData, getCourseOutlineInfoQuery, patchUnitItemQuery, @@ -38,7 +39,6 @@ import { import { useIframe } from './context/hooks'; import { messageTypes, PUBLISH_TYPES } from './constants'; - // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -112,14 +112,18 @@ export const useCourseUnit = ({ courseId, blockId }) => { ); const unitXBlockActions = { - // TODO: use for xblock delete functionality handleDelete: (XBlockId) => { dispatch(deleteUnitItemQuery(blockId, XBlockId)); }, + handleDuplicate: (XBlockId) => { + dispatch(duplicateUnitItemQuery(blockId, XBlockId)); + }, }; const handleRollbackMovedXBlock = () => { - const { sourceLocator, targetParentLocator, title, currentParentLocator } = movedXBlockParams; + const { + sourceLocator, targetParentLocator, title, currentParentLocator, + } = movedXBlockParams; dispatch(patchUnitItemQuery({ sourceLocator, targetParentLocator, @@ -183,6 +187,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleTitleEditSubmit, handleCreateNewCourseXBlock, handleConfigureSubmit, + courseVerticalChildren, canPasteComponent, isMoveModalOpen, openMoveModal, diff --git a/src/course-unit/move-modal/constants.ts b/src/course-unit/move-modal/constants.ts index 397c434900..dddfb46230 100644 --- a/src/course-unit/move-modal/constants.ts +++ b/src/course-unit/move-modal/constants.ts @@ -19,7 +19,7 @@ export const CATEGORIES_KEYS = { component: 'component', split_test: 'split_test', group: 'group', -} +}; export const CATEGORY_RELATION_MAP = { course: 'section', diff --git a/src/course-unit/move-modal/hooks.tsx b/src/course-unit/move-modal/hooks.tsx index a8004f9bf5..0a6898f005 100644 --- a/src/course-unit/move-modal/hooks.tsx +++ b/src/course-unit/move-modal/hooks.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, useMemo, } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +// eslint-disable-next-line import/no-extraneous-dependencies import { IntlShape } from 'react-intl'; import { useParams } from 'react-router-dom'; import { useMediaQuery } from 'react-responsive'; @@ -216,26 +217,6 @@ export const useMoveModal = ({ setDisplayedXBlocksCategories, enableMoveOperation, ]); - console.log( - state.sourceXBlockInfo, - { - isLoading: loadingStatus === RequestStatus.IN_PROGRESS, - isValidMove: state.isValidMove, - isExtraSmall, - parentInfo: state.parentInfo, - childrenInfo: state.childrenInfo, - displayName: state.sourceXBlockInfo.current.displayName, - sourceXBlockId: state.sourceXBlockInfo.current.id, - categoryText: getCategoryText(), - breadcrumbs, - currentXBlockParentIds, - handleXBlockClick, - handleBreadcrumbsClick, - handleCLoseModal, - handleMoveXBlock, - } - ); - return { isLoading: loadingStatus === RequestStatus.IN_PROGRESS, isValidMove: state.isValidMove, diff --git a/src/course-unit/move-modal/index.scss b/src/course-unit/move-modal/index.scss index 9105907cbb..ac6dde3bd6 100644 --- a/src/course-unit/move-modal/index.scss +++ b/src/course-unit/move-modal/index.scss @@ -62,7 +62,7 @@ border-radius: 0; width: 100%; gap: map-get($spacers, 2); - padding: 0.5625rem $spacer 0.5625rem map-get($spacers, 4); + padding: .5625rem $spacer .5625rem map-get($spacers, 4); } .btn { diff --git a/src/course-unit/move-modal/interfaces.ts b/src/course-unit/move-modal/interfaces.ts index bc94ccd097..dd21e7378d 100644 --- a/src/course-unit/move-modal/interfaces.ts +++ b/src/course-unit/move-modal/interfaces.ts @@ -1,95 +1,97 @@ +// eslint-disable-next-line import/export export interface IXBlockInfo { - id: string; - displayName: string; - child_info?: { - children?: IXBlockInfo[]; - }; - category?: string; - has_children?: boolean; + id: string; + displayName: string; + child_info?: { + children?: IXBlockInfo[]; + }; + category?: string; + has_children?: boolean; } export interface IUseMoveModalParams { - isOpenModal: boolean; - closeModal: () => void; - openModal: () => void; - courseId: string; + isOpenModal: boolean; + closeModal: () => void; + openModal: () => void; + courseId: string; } export interface IUseMoveModalReturn { - isLoading: boolean; - isValidMove: boolean; - isExtraSmall: boolean; - parentInfo: { - parent: IXBlockInfo; - category: string; - }; - childrenInfo: { - children: IXBlockInfo[]; - category: string; - }; - displayName: string; - sourceXBlockId: string; - categoryText: string; - breadcrumbs: string[]; - currentXBlockParentIds: string[]; - handleXBlockClick: (newParentIndex: string|number) => void; - handleBreadcrumbsClick: (newParentIndex: string|number) => void; - handleCLoseModal: () => void; - handleMoveXBlock: () => void; + isLoading: boolean; + isValidMove: boolean; + isExtraSmall: boolean; + parentInfo: { + parent: IXBlockInfo; + category: string; + }; + childrenInfo: { + children: IXBlockInfo[]; + category: string; + }; + displayName: string; + sourceXBlockId: string; + categoryText: string; + breadcrumbs: string[]; + currentXBlockParentIds: string[]; + handleXBlockClick: (newParentIndex: string | number) => void; + handleBreadcrumbsClick: (newParentIndex: string | number) => void; + handleCLoseModal: () => void; + handleMoveXBlock: () => void; } export interface IState { - sourceXBlockInfo: { - current: IXBlockInfo; - parent: IXBlockInfo; - }; - childrenInfo: { - children: IXBlockInfo[]; - category: string; - }; - parentInfo: { - parent: IXBlockInfo; - category: string; - }; - visitedAncestors: IXBlockInfo[]; - isValidMove: boolean; + sourceXBlockInfo: { + current: IXBlockInfo; + parent: IXBlockInfo; + }; + childrenInfo: { + children: IXBlockInfo[]; + category: string; + }; + parentInfo: { + parent: IXBlockInfo; + category: string; + }; + visitedAncestors: IXBlockInfo[]; + isValidMove: boolean; } export interface ITreeNode { - id: string; - child_info?: { - children?: ITreeNode[]; - }; + id: string; + child_info?: { + children?: ITreeNode[]; + }; } +// eslint-disable-next-line import/export export interface IXBlockInfo { - category?: string; - hasChildren?: boolean; - has_children?: boolean; + category?: string; + hasChildren?: boolean; + has_children?: boolean; } export interface IAncestor { - category?: string; - display_name?: string; + category?: string; + display_name?: string; } export interface IMoveModalProps { - isOpenModal: boolean, - closeModal: () => void, - openModal: () => void, - courseId: string, + isOpenModal: boolean, + closeModal: () => void, + openModal: () => void, + courseId: string, } export interface IXBlockChildInfo { - category?: string; - display_name?: string; - children?: IXBlock[]; + category?: string; + display_name?: string; + children?: IXBlock[]; } export interface IXBlock { - id: string; - display_name: string; - category: string; - has_children: boolean; - child_info?: IXBlockChildInfo; + id: string; + display_name: string; + category: string; + has_children: boolean; + child_info?: IXBlockChildInfo; } diff --git a/src/course-unit/move-modal/moveModal.test.tsx b/src/course-unit/move-modal/moveModal.test.tsx index 0f19f3f267..2512b2c20e 100644 --- a/src/course-unit/move-modal/moveModal.test.tsx +++ b/src/course-unit/move-modal/moveModal.test.tsx @@ -1,4 +1,4 @@ -import {render, screen, within} from '@testing-library/react'; +import { render, within } from '@testing-library/react'; import { AppProvider } from '@edx/frontend-platform/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; diff --git a/src/course-unit/move-modal/utils.ts b/src/course-unit/move-modal/utils.ts index 4c2e7bb704..21529f7aa3 100644 --- a/src/course-unit/move-modal/utils.ts +++ b/src/course-unit/move-modal/utils.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-extraneous-dependencies import { IntlShape } from 'react-intl'; import { BASIC_BLOCK_TYPES, CATEGORIES_KEYS } from './constants'; @@ -37,6 +38,7 @@ export const findParentIds = ( ): string[] => { let path: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-shadow function traverse(node: ITreeNode | undefined, targetId: string, currentPath: string[]): boolean { if (!node) { return false; @@ -73,7 +75,9 @@ export const isValidCategory = ( sourceParentInfo: IXBlockInfo, targetParentInfo: IXBlockInfo, ): boolean => { + // eslint-disable-next-line prefer-const let { category: sourceParentCategory, hasChildren: sourceParentHasChildren } = sourceParentInfo; + // eslint-disable-next-line prefer-const let { category: targetParentCategory, has_children: targetParentHasChildren } = targetParentInfo; if ( diff --git a/src/course-unit/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx index 424594f35b..0ef08baf28 100644 --- a/src/course-unit/sidebar/PublishControls.jsx +++ b/src/course-unit/sidebar/PublishControls.jsx @@ -4,9 +4,10 @@ import { useToggle } from '@openedx/paragon'; import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import useCourseUnitData from './hooks'; +import { useIframe } from '../context/hooks'; import { editCourseUnitVisibilityAndData } from '../data/thunk'; import { SidebarBody, SidebarFooter, SidebarHeader } from './components'; -import { PUBLISH_TYPES } from '../constants'; +import { PUBLISH_TYPES, messageTypes } from '../constants'; import { getCourseUnitData } from '../data/selectors'; import messages from './messages'; import ModalNotification from '../../generic/modal-notification'; @@ -20,6 +21,7 @@ const PublishControls = ({ blockId }) => { visibleToStaffOnly, } = useCourseUnitData(useSelector(getCourseUnitData)); const intl = useIntl(); + const { sendMessageToIframe } = useIframe(); const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); @@ -34,6 +36,11 @@ const PublishControls = ({ blockId }) => { const handleCourseUnitDiscardChanges = () => { closeDiscardModal(); dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges)); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); }; const handleCourseUnitPublish = () => { diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 67d625ab1d..706111f857 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,57 +1,223 @@ -import { useRef, useEffect, FC } from 'react'; -import PropTypes from 'prop-types'; +import { + useRef, FC, useEffect, useState, +} from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; +import { useToggle } from '@openedx/paragon'; +import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; -import { IFRAME_FEATURE_POLICY } from '../constants'; -import {useIframe} from '../context/hooks'; +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { copyToClipboard } from '../../generic/data/thunks'; +import { COURSE_BLOCK_NAMES, IFRAME_FEATURE_POLICY } from '../../constants'; +import { messageTypes } from '../constants'; +import { fetchCourseUnitQuery } from '../data/thunk'; +import { useIframe } from '../context/hooks'; import { useIFrameBehavior } from './hooks'; import messages from './messages'; -/** - * This offset is necessary to fully display the dropdown actions of the XBlock - * in case the XBlock does not have content inside. - */ -const IFRAME_BOTTOM_OFFSET = 220; - interface XBlockContainerIframeProps { + courseId: string; blockId: string; + unitXBlockActions: { + handleDelete: (XBlockId: string) => void; + handleDuplicate: (XBlockId: string) => void; + }; + xblocks: Array<{ + name: string; + blockId: string; + blockType: string; + userPartitionInfo: { + selectablePartitions: any[]; + selectedPartitionIndex: number; + selectedGroupsLabel: string; + }; + userPartitions: Array<{ + id: number; + name: string; + scheme: string; + groups: Array<{ + id: number; + name: string; + selected: boolean; + deleted: boolean; + }>; + }>; + upstreamLink: string | null; + actions: { + canCopy: boolean; + canDuplicate: boolean; + canMove: boolean; + canManageAccess: boolean; + canDelete: boolean; + canManageTags: boolean; + }; + validationMessages: any[]; + renderError: string; + id: string; + }>; + handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void; } -const XBlockContainerIframe: FC = ({ blockId }) => { +const XBlockContainerIframe: FC = ({ + courseId, blockId, unitXBlockActions, xblocks, handleConfigureSubmit, +}) => { const intl = useIntl(); const iframeRef = useRef(null); - const { setIframeRef } = useIframe(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const [deleteXblockId, setDeleteXblockId] = useState(null); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const { setIframeRef, sendMessageToIframe } = useIframe(); + const [editXblockId, setEditXblockId] = useState(null); + const [currentXblockData, setCurrentXblockData] = useState({}); const iframeUrl = `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`; + useEffect(() => { + setIframeRef(iframeRef); + }, [setIframeRef]); + + const handleDelete = (id: string) => { + openDeleteModal(); + setDeleteXblockId(id); + }; + + const handleConfigure = (id: string) => { + openConfigureModal(); + setEditXblockId(id); + + const foundXBlockInfo = xblocks?.find(block => block.blockId === id); + + if (foundXBlockInfo) { + const { name, userPartitionInfo } = foundXBlockInfo; + + setCurrentXblockData({ + category: COURSE_BLOCK_NAMES.component.id, + displayName: name, + userPartitionInfo, + showCorrectness: 'always', + }); + } + }; + + const handleCopy = (id: string) => { + dispatch(copyToClipboard(id)); + }; + + const handleDuplicateXBlock = (id) => { + if (id) { + unitXBlockActions.handleDuplicate(id); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); + } + }; + + const handleRefreshXBlocks = () => { + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + dispatch(fetchCourseUnitQuery(blockId)); + }, 1000); + }; + + const navigateToNewXBlockEditor = (url: string) => { + navigate(`/course/${courseId}/editor${url}`); + }; + + useEffect(() => { + const messageHandlers: Record void> = { + [messageTypes.deleteXBlock]: (payload) => handleDelete(payload.id), + [messageTypes.manageXBlockAccess]: (payload) => handleConfigure(payload.id), + [messageTypes.copyXBlock]: (payload) => handleCopy(payload.id), + [messageTypes.duplicateXBlock]: (payload) => handleDuplicateXBlock(payload.id), + [messageTypes.refreshPositions]: handleRefreshXBlocks, + [messageTypes.newXBlockEditor]: (payload) => navigateToNewXBlockEditor(payload.url), + }; + + const handleMessage = (event: MessageEvent) => { + const { type, payload } = event.data || {}; + + if (type && messageHandlers[type]) { + messageHandlers[type](payload); + } + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [dispatch, blockId, xblocks]); + const { iframeHeight } = useIFrameBehavior({ id: blockId, iframeUrl, }); - useEffect(() => { - setIframeRef(iframeRef); - }, [setIframeRef]); + const handleDeleteItemSubmit = () => { + if (deleteXblockId) { + unitXBlockActions.handleDelete(deleteXblockId); + closeDeleteModal(); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); + } + }; + + const onConfigureSubmit = (...args: any[]) => { + if (editXblockId) { + handleConfigureSubmit(editXblockId, ...args, closeConfigureModal); + // TODO: this artificial delay is a temporary solution + // to ensure the iframe content is properly refreshed. + setTimeout(() => { + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, 1000); + } + }; return ( -