From ec0ae6bba0cfc7d91af4285a4292e3c5993f8751 Mon Sep 17 00:00:00 2001 From: Peter Kulko <93188219+pkulkoraccoongang@users.noreply.github.com> Date: Tue, 9 Apr 2024 09:44:15 +0200 Subject: [PATCH 1/2] feat: added edit and move modals for xblocks * feat: [AXIMST-28] added edit modals for xblocks * refactor: added postMessages * refactor: modals refactoring * refactor: code refactoring * refactor: after rebase * refactor: styles refactoring * refactor: code refactoring * refactor: create iframe component * refactor: error handling * fix: fixed tests * refactor: removed error handling * refactor: refactoring after review feat: added move modal and tests feat: added alert for successful unit movement feat: added alert logic refactor: code refactoring refactor: some refactoring refactor: code refactoring refactor: code refactoring refactor: refactoring after review refactor: after second review refactor: after rebase refactor: code refactoring refactor: tests refactoring --- src/constants.js | 1 + src/course-unit/CourseUnit.jsx | 41 +++- src/course-unit/CourseUnit.test.jsx | 168 ++++++++++++- .../course-xblock/CourseIFrame.jsx | 35 +++ .../course-xblock/CourseXBlock.jsx | 228 ++++++++++++------ .../course-xblock/CourseXBlock.scss | 14 ++ .../course-xblock/CourseXBlock.test.jsx | 4 + src/course-unit/course-xblock/constants.js | 5 +- src/course-unit/course-xblock/utils.js | 11 + src/course-unit/data/api.js | 16 ++ src/course-unit/data/selectors.js | 1 + src/course-unit/data/slice.js | 11 + src/course-unit/data/thunk.js | 23 ++ src/course-unit/hooks.jsx | 28 ++- src/course-unit/messages.js | 30 +++ src/generic/hooks/index.js | 2 + src/generic/hooks/useOverflowControl.js | 28 +++ src/generic/hooks/useOverflowControl.test.jsx | 53 ++++ 18 files changed, 620 insertions(+), 79 deletions(-) create mode 100644 src/course-unit/course-xblock/CourseIFrame.jsx create mode 100644 src/course-unit/course-xblock/utils.js create mode 100644 src/generic/hooks/index.js create mode 100644 src/generic/hooks/useOverflowControl.js create mode 100644 src/generic/hooks/useOverflowControl.test.jsx 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..92005850e5 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'; @@ -61,6 +66,10 @@ const CourseUnit = ({ courseId }) => { courseVerticalChildren, handleXBlockDragAndDrop, canPasteComponent, + movedXBlockParams, + handleRollbackMovedXBlock, + handleCloseXBlockMovedAlert, + handleNavigateToTargetUnit, } = useCourseUnit({ courseId, blockId }); const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]); @@ -101,6 +110,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..53d909ee8d 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 = () => ( @@ -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) => ( +