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 99035ed1b1..c9b8c2606d 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -2,11 +2,16 @@ 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 { getConfig } from '@edx/frontend-platform'; 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'; @@ -38,6 +43,7 @@ const CourseUnit = ({ courseId }) => { const intl = useIntl(); const { isLoading, + isLoadingFailed, sequenceId, unitTitle, isQueryPending, @@ -61,6 +67,10 @@ const CourseUnit = ({ courseId }) => { courseVerticalChildren, handleXBlockDragAndDrop, canPasteComponent, + movedXBlockParams, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]); @@ -83,7 +93,7 @@ const CourseUnit = ({ courseId }) => { return ; } - if (sequenceStatus === RequestStatus.FAILED) { + if (isLoadingFailed || sequenceStatus === RequestStatus.FAILED) { return ( @@ -101,6 +111,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 91e8a2f51a..9b82a657a3 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -6,8 +6,7 @@ import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { - camelCaseObject, - getConfig, + camelCaseObject, getConfig, initializeMockApp, setConfig, } from '@edx/frontend-platform'; @@ -28,6 +27,7 @@ import { fetchCourseSectionVerticalData, fetchCourseUnitQuery, fetchCourseVerticalChildrenData, + rollbackUnitItemQuery, } from './data/thunk'; import initializeStore from '../store'; import { @@ -110,8 +110,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 = () => ( @@ -134,9 +149,9 @@ describe('', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock - .onGet(getCourseUnitApiUrl(courseId)) + .onGet(getCourseUnitApiUrl(blockId)) .reply(200, courseUnitIndexMock); - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); @@ -1021,7 +1036,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); axiosMock .onGet(getCourseUnitApiUrl(blockId)) - .replyOnce(200, { + .reply(200, { ...courseUnitIndexMock, visibility_state: UNIT_VISIBILITY_STATES.staffOnly, has_explicit_staff_lock: true, @@ -1522,4 +1537,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) => ( +