diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 03a2669a51..592865c19f 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -71,6 +71,7 @@ const CourseUnit = ({ courseId }) => { movedXBlockParams, handleRollbackMovedXBlock, handleCloseXBlockMovedAlert, + handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]); @@ -119,6 +120,7 @@ const CourseUnit = ({ courseId }) => { {movedXBlockParams.isSuccess ? ( { , + , ]} onClose={handleCloseXBlockMovedAlert} /> diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index a5976e87bc..201fb6b116 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,20 +23,18 @@ import { fetchCourseSectionVerticalData, fetchCourseUnitQuery, fetchCourseVerticalChildrenData, + rollbackUnitItemQuery, } from './data/thunk'; import initializeStore from '../store'; import { + clipboardMockResponse, courseCreateXblockMock, courseSectionVerticalMock, courseUnitIndexMock, courseUnitMock, courseVerticalChildrenMock, - clipboardMockResponse, } from './__mocks__'; -import { - clipboardUnit, - clipboardXBlock, -} from '../__mocks__'; +import { clipboardUnit, clipboardXBlock } from '../__mocks__'; import { executeThunk } from '../utils'; import deleteModalMessages from '../generic/delete-modal/messages'; import pasteComponentMessages from '../generic/clipboard/paste-component/messages'; @@ -111,6 +109,17 @@ jest.mock('../generic/hooks', () => ({ 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 = () => ( @@ -1590,4 +1599,153 @@ describe('', () => { }); }); }); + + 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_edit_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/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 945ba69bef..003c7ed870 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -85,7 +85,7 @@ const CourseXBlock = memo(({ useEffect(() => { const handleMessage = (event) => { - const { method } = event.data; + const { method, params } = event.data; if (method === 'close_edit_modal') { toggleLegacyEditModal(false); @@ -97,8 +97,14 @@ const CourseXBlock = memo(({ dispatch(fetchCourseVerticalChildrenData(blockId)); dispatch(fetchXBlockIFrameHtmlAndResourcesQuery(id)); dispatch(fetchCourseUnitQuery(blockId)); - } else if (method === 'moving_xblock') { - dispatch(updateMovedXBlockParams({ title, isSuccess: true, sourceLocator: id })); + } else if (method === 'move_xblock') { + toggleLegacyMoveModal(false); + dispatch(updateMovedXBlockParams({ + title: params.sourceDisplayName, + isSuccess: true, + sourceLocator: params.sourceLocator, + targetParentLocator: params.targetParentLocator, + })); window.scrollTo({ top: 0, behavior: 'smooth' }); } }; @@ -162,7 +168,13 @@ const CourseXBlock = memo(({ const handleXBlockMove = () => { toggleLegacyMoveModal(true); - dispatch(updateMovedXBlockParams({ isSuccess: false })); + dispatch(updateMovedXBlockParams({ + isSuccess: false, + isUndo: false, + title: '', + sourceLocator: '', + targetParentLocator: '', + })); }; const onConfigureSubmit = (...arg) => { diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 3359ceedee..e993eed5a0 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -155,14 +155,14 @@ export async function duplicateUnitItem(itemId, XBlockId) { /** * Rolls back a unit item to its previous state. * @param {string} itemId - The ID of the item to be rolled back. - * @param {string} XBlockId - The ID of the XBlock associated with the item. + * @param {string} xblockId - The ID of the XBlock associated with the item. * @returns {Promise} - A promise that resolves to the response data from the server. */ -export async function rollbackUnitItem(itemId, XBlockId) { +export async function rollbackUnitItem(itemId, xblockId) { const { data } = await getAuthenticatedHttpClient() .patch(postXBlockBaseApiUrl(), { parent_locator: itemId, - move_source_locator: XBlockId, + move_source_locator: xblockId, }); return data; diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 2735b806c4..948bf36c58 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -16,6 +16,7 @@ const slice = createSlice({ isUndo: false, title: '', sourceLocator: '', + targetParentLocator: '', }, loadingStatus: { fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS, diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 1580da5ea4..a4765a896e 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -136,6 +136,10 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(updateMovedXBlockParams({ isSuccess: false })); }; + const handleNavigateToTargetUnit = () => { + navigate(`/course/${courseId}/container/${movedXBlockParams.targetParentLocator}`); + }; + useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { dispatch(updateQueryPendingStatus(true)); @@ -180,6 +184,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleRollbackMovedXBlock, handleCloseXBlockMovedAlert, movedXBlockParams, + handleNavigateToTargetUnit, canPasteComponent, }; }; diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js index fc66e2ebd7..32ea37ef6c 100644 --- a/src/course-unit/messages.js +++ b/src/course-unit/messages.js @@ -41,6 +41,10 @@ const messages = defineMessages({ id: 'course-authoring.course-unit.alert.xblock.move.undo.btn.text', defaultMessage: 'Undo move', }, + newLocationButton: { + id: 'course-authoring.course-unit.alert.xblock.new.location.btn.text', + defaultMessage: 'Take me to the new location', + }, }); export default messages;