diff --git a/src/constants.js b/src/constants.js index a641c8add8..8cc99b58d4 100644 --- a/src/constants.js +++ b/src/constants.js @@ -27,6 +27,7 @@ export const NOTIFICATION_MESSAGES = { copying: 'Copying', pasting: 'Pasting', discardChanges: 'Discarding changes', + undoMoving: 'Undo moving', publishing: 'Publishing', hidingFromStudents: 'Hiding from students', makingVisibleToStudents: 'Making visible to students', diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index f82d80dd98..5ebf803b85 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { Container, Layout, Stack } from '@openedx/paragon'; +import { + Container, Layout, Stack, Button, TransitionReplace, +} from '@openedx/paragon'; import { useIntl, injectIntl } from '@edx/frontend-platform/i18n'; import { DraggableList, ErrorAlert } from '@edx/frontend-lib-content-components'; -import { Warning as WarningIcon } from '@openedx/paragon/icons'; +import { + Warning as WarningIcon, + CheckCircle as CheckCircleIcon, +} from '@openedx/paragon/icons'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; @@ -60,6 +65,10 @@ const CourseUnit = ({ courseId }) => { courseVerticalChildren, handleXBlockDragAndDrop, canPasteComponent, + movedXBlockParams, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]); @@ -100,6 +109,34 @@ const CourseUnit = ({ courseId }) => { <>
+ + {movedXBlockParams.isSuccess ? ( + + {intl.formatMessage(messages.undoMoveButton)} + , + , + ]} + onClose={handleCloseXBlockMovedAlert} + /> + ) : null} + {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 95116cc1a3..026bc45404 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -5,7 +5,7 @@ import { import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; -import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; +import { camelCaseObject, getConfig, initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { cloneDeep, set } from 'lodash'; @@ -23,6 +23,7 @@ import { fetchCourseSectionVerticalData, fetchCourseUnitQuery, fetchCourseVerticalChildrenData, + rollbackUnitItemQuery, } from './data/thunk'; import initializeStore from '../store'; import { @@ -105,8 +106,23 @@ const clipboardBroadcastChannelMock = { close: jest.fn(), }; +jest.mock('../generic/hooks', () => ({ + useOverflowControl: () => jest.fn(), +})); + global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); +const getIFramePostMessages = (method) => ({ + data: { + method, + params: { + targetParentLocator: courseId, + sourceDisplayName: courseVerticalChildrenMock.children[0].name, + sourceLocator: courseVerticalChildrenMock.children[0].block_id, + }, + }, +}); + const RootWrapper = () => ( @@ -1499,4 +1515,153 @@ describe('', () => { expect(xBlock1).toBe(xBlock2); }); }); + + describe('Edit and move modals', () => { + it('should close the edit modal when the close button is clicked', async () => { + const { getByTitle, getAllByTestId } = render(); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, courseVerticalChildrenMock); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + const [discussionXBlock] = getAllByTestId('course-xblock'); + const xblockEditBtn = within(discussionXBlock) + .getByLabelText(courseXBlockMessages.blockAltButtonEdit.defaultMessage); + + userEvent.click(xblockEditBtn); + + const iframePostMsg = getIFramePostMessages('close_modal'); + const editModalIFrame = getByTitle('xblock-edit-modal-iframe'); + + expect(editModalIFrame).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/xblock/${courseVerticalChildrenMock.children[0].block_id}/actions/edit`); + + await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg))); + + expect(editModalIFrame).not.toBeInTheDocument(); + }); + + it('should display success alert and close move modal when move event is triggered', async () => { + const { + getByTitle, + getByRole, + getAllByLabelText, + getByText, + } = render(); + + const iframePostMsg = getIFramePostMessages('move_xblock'); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, courseVerticalChildrenMock); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); + userEvent.click(xblockActionBtn); + + const xblockMoveBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonMove.defaultMessage }); + userEvent.click(xblockMoveBtn); + + const moveModalIFrame = getByTitle('xblock-move-modal-iframe'); + + await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg))); + + expect(moveModalIFrame).not.toBeInTheDocument(); + expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText( + messages.alertMoveSuccessDescription.defaultMessage + .replace('{title}', courseVerticalChildrenMock.children[0].name), + )).toBeInTheDocument(); + + await waitFor(() => { + userEvent.click(getByText(/Cancel/i)); + expect(moveModalIFrame).not.toBeInTheDocument(); + }); + }); + + it('should navigate to new location when new location button is clicked after successful move', async () => { + const { + getByTitle, + getByRole, + getAllByLabelText, + getByText, + } = render(); + + const iframePostMsg = getIFramePostMessages('move_xblock'); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, courseVerticalChildrenMock); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); + userEvent.click(xblockActionBtn); + + const xblockMoveBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonMove.defaultMessage }); + userEvent.click(xblockMoveBtn); + + const moveModalIFrame = getByTitle('xblock-move-modal-iframe'); + + await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg))); + + expect(moveModalIFrame).not.toBeInTheDocument(); + expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText( + messages.alertMoveSuccessDescription.defaultMessage + .replace('{title}', courseVerticalChildrenMock.children[0].name), + )).toBeInTheDocument(); + + await waitFor(() => { + userEvent.click(getByText(messages.newLocationButton.defaultMessage)); + expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${iframePostMsg.data.params.targetParentLocator}`); + }); + }); + + it('should display move cancellation alert when undo move button is clicked', async () => { + const { + getByRole, + getAllByLabelText, + getByText, + } = render(); + + const iframePostMsg = getIFramePostMessages('move_xblock'); + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, courseVerticalChildrenMock); + + await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); + + const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); + userEvent.click(xblockActionBtn); + + const xblockMoveBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonMove.defaultMessage }); + userEvent.click(xblockMoveBtn); + + await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg))); + + await waitFor(() => userEvent.click(getByText(messages.undoMoveButton.defaultMessage))); + + axiosMock + .onPatch(postXBlockBaseApiUrl(), { + parent_locator: blockId, + move_source_locator: courseVerticalChildrenMock.children[0].block_id, + }) + .reply(200, { + parent_locator: blockId, + move_source_locator: courseVerticalChildrenMock.children[0].block_id, + }); + + await executeThunk(rollbackUnitItemQuery(blockId, courseVerticalChildrenMock.children[0].block_id, 'Discussion'), store.dispatch); + + expect(getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText( + messages.alertMoveCancelDescription.defaultMessage + .replace('{title}', courseVerticalChildrenMock.children[0].name), + )).toBeInTheDocument(); + }); + }); }); diff --git a/src/course-unit/course-xblock/CourseIFrame.jsx b/src/course-unit/course-xblock/CourseIFrame.jsx new file mode 100644 index 0000000000..609b0ae166 --- /dev/null +++ b/src/course-unit/course-xblock/CourseIFrame.jsx @@ -0,0 +1,35 @@ +import { forwardRef } from 'react'; +import PropTypes from 'prop-types'; + +import { IFRAME_FEATURE_POLICY } from './constants'; + +const CourseIFrame = forwardRef(({ title, ...props }, ref) => ( +