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) => (
+
+));
+
+CourseIFrame.propTypes = {
+ title: PropTypes.string.isRequired,
+};
+
+export default CourseIFrame;
diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx
index 88058ee5a5..ac7b4e8d1e 100644
--- a/src/course-unit/course-xblock/CourseXBlock.jsx
+++ b/src/course-unit/course-xblock/CourseXBlock.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -9,20 +9,27 @@ import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/para
import { useIntl } from '@edx/frontend-platform/i18n';
import { useNavigate, useSearchParams } from 'react-router-dom';
-import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
+import { getCanEdit, getCourseId } from '../data/selectors';
+import { useOverflowControl } from '../../generic/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import SortableItem from '../../generic/drag-helper/SortableItem';
import { scrollToElement } from '../../course-outline/utils';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { copyToClipboard } from '../../generic/data/thunks';
+import { fetchCourseUnitQuery, fetchCourseVerticalChildrenData } from '../data/thunk';
+import { updateMovedXBlockParams } from '../data/slice';
import { COMPONENT_TYPES } from '../constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
+import { getXBlockActionsBasePath } from './utils';
+import CourseIFrame from './CourseIFrame';
+
+const XBLOCK_LEGACY_MODAL_CLASS_NAME = 'xblock-edit-modal';
const CourseXBlock = ({
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
- handleConfigureSubmit, validationMessages, ...props
+ handleConfigureSubmit, validationMessages, renderError, blockId, ...props
}) => {
const courseXBlockElementRef = useRef(null);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
@@ -32,6 +39,38 @@ const CourseXBlock = ({
const canEdit = useSelector(getCanEdit);
const courseId = useSelector(getCourseId);
const intl = useIntl();
+ const [showLegacyEditModal, toggleLegacyEditModal] = useState(false);
+ const [showLegacyMoveModal, toggleLegacyMoveModal] = useState(false);
+ const xblockLegacyModalRef = useRef(null);
+
+ useOverflowControl(`.${XBLOCK_LEGACY_MODAL_CLASS_NAME}`);
+
+ useEffect(() => {
+ const handleMessage = (event) => {
+ const { method, params } = event.data;
+
+ if (method === 'close_modal') {
+ toggleLegacyEditModal(false);
+ dispatch(fetchCourseVerticalChildrenData(blockId));
+ dispatch(fetchCourseUnitQuery(blockId));
+ } 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' });
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ return () => {
+ window.removeEventListener('message', handleMessage);
+ };
+ }, [xblockLegacyModalRef]);
const [searchParams] = useSearchParams();
const locatorId = searchParams.get('show');
@@ -41,6 +80,10 @@ const CourseXBlock = ({
? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel })
: null;
+ useEffect(() => {
+ localStorage.removeItem('editedXBlockId');
+ }, []);
+
const currentItemData = {
category: COURSE_BLOCK_NAMES.component.id,
displayName: title,
@@ -61,9 +104,22 @@ const CourseXBlock = ({
navigate(`/course/${courseId}/editor/${type}/${id}`);
break;
default:
+ toggleLegacyEditModal(true);
+ localStorage.setItem('editedXBlockId', id);
}
};
+ const handleXBlockMove = () => {
+ toggleLegacyMoveModal(true);
+ dispatch(updateMovedXBlockParams({
+ isSuccess: false,
+ isUndo: false,
+ title: '',
+ sourceLocator: '',
+ targetParentLocator: '',
+ }));
+ };
+
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(id, ...arg, closeConfigureModal);
};
@@ -76,92 +132,118 @@ const CourseXBlock = ({
}, [isScrolledToElement]);
return (
-
-
+ {showLegacyEditModal && (
+
+
+
+ )}
+ {showLegacyMoveModal && (
+
+
+
+ )}
+
-
-
-
-
+
+
-
- unitXBlockActions.handleDuplicate(id)}>
- {intl.formatMessage(messages.blockLabelButtonDuplicate)}
-
-
- {intl.formatMessage(messages.blockLabelButtonMove)}
-
- {canEdit && (
- dispatch(copyToClipboard(id))}>
- {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
+
+
+
+ unitXBlockActions.handleDuplicate(id)}>
+ {intl.formatMessage(messages.blockLabelButtonDuplicate)}
+
+
+ {intl.formatMessage(messages.blockLabelButtonMove)}
- )}
-
- {intl.formatMessage(messages.blockLabelButtonManageAccess)}
-
-
- {intl.formatMessage(messages.blockLabelButtonDelete)}
-
-
-
-
-
-
- )}
- />
-
-
-
-
-
-
+ {canEdit && (
+ dispatch(copyToClipboard(id))}>
+ {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)}
+
+ )}
+
+ {intl.formatMessage(messages.blockLabelButtonManageAccess)}
+
+
+ {intl.formatMessage(messages.blockLabelButtonDelete)}
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+
+ >
);
};
CourseXBlock.defaultProps = {
validationMessages: [],
shouldScroll: false,
+ renderError: undefined,
};
CourseXBlock.propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
+ blockId: PropTypes.string.isRequired,
+ renderError: PropTypes.string,
shouldScroll: PropTypes.bool,
validationMessages: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.string,
diff --git a/src/course-unit/course-xblock/CourseXBlock.scss b/src/course-unit/course-xblock/CourseXBlock.scss
index 4ae9f6dab1..b6c2465f2e 100644
--- a/src/course-unit/course-xblock/CourseXBlock.scss
+++ b/src/course-unit/course-xblock/CourseXBlock.scss
@@ -34,3 +34,17 @@
margin-bottom: 0;
}
}
+
+.xblock-edit-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: $zindex-modal;
+
+ iframe {
+ width: inherit;
+ height: inherit;
+ }
+}
diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx
index ad8e09184b..4133e3f793 100644
--- a/src/course-unit/course-xblock/CourseXBlock.test.jsx
+++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx
@@ -51,6 +51,10 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
+jest.mock('../../generic/hooks', () => ({
+ useOverflowControl: () => jest.fn(),
+}));
+
const renderComponent = (props) => render(
diff --git a/src/course-unit/course-xblock/constants.js b/src/course-unit/course-xblock/constants.js
index 5f0177ce72..7a730d1602 100644
--- a/src/course-unit/course-xblock/constants.js
+++ b/src/course-unit/course-xblock/constants.js
@@ -1,4 +1,7 @@
-// eslint-disable-next-line import/prefer-default-export
+export const IFRAME_FEATURE_POLICY = (
+ 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture;'
+);
+
export const MESSAGE_ERROR_TYPES = {
error: 'error',
warning: 'warning',
diff --git a/src/course-unit/course-xblock/utils.js b/src/course-unit/course-xblock/utils.js
new file mode 100644
index 0000000000..aa70cdabbd
--- /dev/null
+++ b/src/course-unit/course-xblock/utils.js
@@ -0,0 +1,11 @@
+import { getConfig } from '@edx/frontend-platform';
+
+/**
+ * Retrieves the base path for XBlock actions.
+ * @param {string} xblockId - The ID of the XBlock.
+ * @returns {string} The base path for XBlock actions.
+ */
+// eslint-disable-next-line import/prefer-default-export
+export function getXBlockActionsBasePath(xblockId) {
+ return `${getConfig().STUDIO_BASE_URL}/xblock/${xblockId}/actions`;
+}
diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js
index 155e9d9878..dd1a2e42c8 100644
--- a/src/course-unit/data/api.js
+++ b/src/course-unit/data/api.js
@@ -149,6 +149,22 @@ export async function duplicateUnitItem(itemId, XBlockId) {
return data;
}
+/**
+ * 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.
+ * @returns {Promise} - A promise that resolves to the response data from the server.
+ */
+export async function rollbackUnitItem(itemId, xblockId) {
+ const { data } = await getAuthenticatedHttpClient()
+ .patch(postXBlockBaseApiUrl(), {
+ parent_locator: itemId,
+ move_source_locator: xblockId,
+ });
+
+ return data;
+}
+
/**
* Sets the order list of XBlocks.
* @param {string} blockId - The identifier of the course unit.
diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js
index 41cb7ea912..fd2dc381ed 100644
--- a/src/course-unit/data/selectors.js
+++ b/src/course-unit/data/selectors.js
@@ -13,8 +13,14 @@ export const getCourseId = (state) => state.courseDetail.courseId;
export const getSequenceId = (state) => state.courseUnit.sequenceId;
export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerticalChildren;
const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
+export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams;
export const getIsLoading = createSelector(
[getLoadingStatuses],
loadingStatus => Object.values(loadingStatus)
.some((status) => status === RequestStatus.IN_PROGRESS),
);
+export const getIsLoadingFailed = createSelector(
+ [getLoadingStatuses],
+ loadingStatus => Object.values(loadingStatus)
+ .some((status) => status === RequestStatus.FAILED),
+);
diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js
index 24c1b965cd..cf2a0ba12f 100644
--- a/src/course-unit/data/slice.js
+++ b/src/course-unit/data/slice.js
@@ -10,6 +10,13 @@ const slice = createSlice({
isQueryPending: false,
isTitleEditFormOpen: false,
canEdit: true,
+ movedXBlockParams: {
+ isSuccess: false,
+ isUndo: false,
+ title: '',
+ sourceLocator: '',
+ targetParentLocator: '',
+ },
loadingStatus: {
fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS,
courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS,
@@ -33,6 +40,9 @@ const slice = createSlice({
updateQueryPendingStatus: (state, { payload }) => {
state.isQueryPending = payload;
},
+ updateMovedXBlockParams: (state, { payload }) => {
+ state.movedXBlockParams = { ...state.movedXBlockParams, ...payload };
+ },
changeEditTitleFormOpen: (state, { payload }) => {
state.isTitleEditFormOpen = payload;
},
@@ -63,12 +73,6 @@ const slice = createSlice({
courseSectionVerticalLoadingStatus: payload.status,
};
},
- updateLoadingCourseXblockStatus: (state, { payload }) => {
- state.loadingStatus = {
- ...state.loadingStatus,
- createUnitXblockLoadingStatus: payload.status,
- };
- },
addNewUnitStatus: (state, { payload }) => {
state.loadingStatus = {
...state.loadingStatus,
@@ -123,13 +127,13 @@ export const {
updateLoadingCourseSectionVerticalDataStatus,
changeEditTitleFormOpen,
updateQueryPendingStatus,
- updateLoadingCourseXblockStatus,
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
deleteXBlock,
duplicateXBlock,
fetchStaticFileNoticesSuccess,
reorderXBlockList,
+ updateMovedXBlockParams,
} = slice.actions;
export const {
diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js
index 109e121c7e..114224216b 100644
--- a/src/course-unit/data/thunk.js
+++ b/src/course-unit/data/thunk.js
@@ -18,6 +18,7 @@ import {
deleteUnitItem,
duplicateUnitItem,
setXBlockOrderList,
+ rollbackUnitItem,
} from './api';
import {
updateLoadingCourseUnitStatus,
@@ -28,7 +29,6 @@ import {
fetchSequenceSuccess,
fetchCourseSectionVerticalDataSuccess,
updateLoadingCourseSectionVerticalDataStatus,
- updateLoadingCourseXblockStatus,
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
updateQueryPendingStatus,
@@ -36,6 +36,7 @@ import {
duplicateXBlock,
fetchStaticFileNoticesSuccess,
reorderXBlockList,
+ updateMovedXBlockParams,
} from './slice';
import { getNotificationMessage } from './utils';
@@ -145,7 +146,6 @@ export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAc
export function createNewCourseXBlock(body, callback, blockId) {
return async (dispatch) => {
- dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS }));
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
if (body.stagedContent) {
@@ -173,7 +173,6 @@ export function createNewCourseXBlock(body, callback, blockId) {
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
- dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
if (callback) {
callback(result);
@@ -185,7 +184,6 @@ export function createNewCourseXBlock(body, callback, blockId) {
});
} catch (error) {
dispatch(hideProcessingNotification());
- dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.FAILED }));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
@@ -249,6 +247,27 @@ export function duplicateUnitItemQuery(itemId, xblockId) {
};
}
+export function rollbackUnitItemQuery(itemId, xblockId, title) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.undoMoving));
+
+ try {
+ await rollbackUnitItem(itemId, xblockId);
+ const newCourseVerticalChildren = await getCourseVerticalChildren(itemId);
+ dispatch(updateCourseVerticalChildren(newCourseVerticalChildren));
+ dispatch(updateMovedXBlockParams({ title, isSuccess: true, isUndo: true }));
+ const courseUnit = await getCourseUnitData(itemId);
+ dispatch(fetchCourseItemSuccess(courseUnit));
+ dispatch(hideProcessingNotification());
+ dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(hideProcessingNotification());
+ dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
+ }
+ };
+}
+
export function setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx
index 91601a0503..aae7bc21b1 100644
--- a/src/course-unit/hooks.jsx
+++ b/src/course-unit/hooks.jsx
@@ -13,6 +13,7 @@ import {
duplicateUnitItemQuery,
setXBlockOrderListQuery,
editCourseUnitVisibilityAndData,
+ rollbackUnitItemQuery,
} from './data/thunk';
import {
getCourseSectionVertical,
@@ -22,12 +23,19 @@ import {
getSavingStatus,
getSequenceStatus,
getStaticFileNotices,
+ getIsLoadingFailed,
getCanEdit,
+ getMovedXBlockParams,
} from './data/selectors';
-import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
+import {
+ changeEditTitleFormOpen,
+ updateQueryPendingStatus,
+ updateMovedXBlockParams,
+} from './data/slice';
import { PUBLISH_TYPES } from './constants';
import { useCopyToClipboard } from '../generic/clipboard';
+import { createCorrectInternalRoute } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export const useCourseUnit = ({ courseId, blockId }) => {
@@ -39,6 +47,8 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const courseUnit = useSelector(getCourseUnitData);
const savingStatus = useSelector(getSavingStatus);
const isLoading = useSelector(getIsLoading);
+ const isLoadingFailed = useSelector(getIsLoadingFailed);
+ const movedXBlockParams = useSelector(getMovedXBlockParams);
const sequenceStatus = useSelector(getSequenceStatus);
const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical);
const courseVerticalChildren = useSelector(getCourseVerticalChildren);
@@ -116,6 +126,19 @@ export const useCourseUnit = ({ courseId, blockId }) => {
dispatch(setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback));
};
+ const handleRollbackMovedXBlock = () => {
+ dispatch(rollbackUnitItemQuery(blockId, movedXBlockParams.sourceLocator, movedXBlockParams.title));
+ };
+
+ const handleCloseXBlockMovedAlert = () => {
+ dispatch(updateMovedXBlockParams({ isSuccess: false }));
+ };
+
+ const handleNavigateToTargetUnit = () => {
+ const correctInternalRoute = createCorrectInternalRoute(`/course/${courseId}/container/${movedXBlockParams.targetParentLocator}`);
+ navigate(correctInternalRoute);
+ };
+
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateQueryPendingStatus(true));
@@ -130,6 +153,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
dispatch(fetchCourseVerticalChildrenData(blockId));
handleNavigate(sequenceId);
+ dispatch(updateMovedXBlockParams({ isSuccess: false }));
}, [courseId, blockId, sequenceId]);
return {
@@ -143,6 +167,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
staticFileNotices,
currentlyVisibleToStudents,
isLoading,
+ isLoadingFailed,
isTitleEditFormOpen,
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
sharedClipboardData,
@@ -157,6 +182,10 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleConfigureSubmit,
courseVerticalChildren,
handleXBlockDragAndDrop,
+ handleRollbackMovedXBlock,
+ handleCloseXBlockMovedAlert,
+ movedXBlockParams,
+ handleNavigateToTargetUnit,
canPasteComponent,
};
};
diff --git a/src/course-unit/messages.js b/src/course-unit/messages.js
index 4f0418efe5..83779747a0 100644
--- a/src/course-unit/messages.js
+++ b/src/course-unit/messages.js
@@ -13,6 +13,36 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.paste-component.btn.text',
defaultMessage: 'Paste component',
},
+ alertMoveSuccessTitle: {
+ id: 'course-authoring.course-unit.alert.xblock.move.success.title',
+ defaultMessage: 'Success!',
+ description: 'Title for the success alert when an XBlock is moved successfully',
+ },
+ alertMoveSuccessDescription: {
+ id: 'course-authoring.course-unit.alert.xblock.move.success.description',
+ defaultMessage: '{title} has been moved',
+ description: 'Description for the success alert when an XBlock is moved successfully',
+ },
+ alertMoveCancelTitle: {
+ id: 'course-authoring.course-unit.alert.xblock.move.cancel.title',
+ defaultMessage: 'Move cancelled',
+ description: 'Title for the alert when moving an XBlock is cancelled',
+ },
+ alertMoveCancelDescription: {
+ id: 'course-authoring.course-unit.alert.xblock.move.cancel.description',
+ defaultMessage: '{title} has been moved back to its original location',
+ description: 'Description for the alert when moving an XBlock is cancelled and the XBlock is moved back to its original location',
+ },
+ undoMoveButton: {
+ id: 'course-authoring.course-unit.alert.xblock.move.undo.btn.text',
+ defaultMessage: 'Undo move',
+ description: 'Text for the button allowing users to undo a move action of an XBlock',
+ },
+ newLocationButton: {
+ id: 'course-authoring.course-unit.alert.xblock.new.location.btn.text',
+ defaultMessage: 'Take me to the new location',
+ description: 'Text for the button allowing users to navigate to the new location after an XBlock has been moved',
+ },
});
export default messages;
diff --git a/src/generic/hooks/index.js b/src/generic/hooks/index.js
new file mode 100644
index 0000000000..503dde9517
--- /dev/null
+++ b/src/generic/hooks/index.js
@@ -0,0 +1,2 @@
+/* eslint-disable import/prefer-default-export */
+export { default as useOverflowControl } from './useOverflowControl';
diff --git a/src/generic/hooks/useOverflowControl.js b/src/generic/hooks/useOverflowControl.js
new file mode 100644
index 0000000000..60f34722e5
--- /dev/null
+++ b/src/generic/hooks/useOverflowControl.js
@@ -0,0 +1,28 @@
+import { useEffect } from 'react';
+
+/**
+ * Hook to control the overflow property of the body based on the presence of an element in the DOM.
+ * @param {string} targetSelector - Selector of the target element for overflow control.
+ * @returns {void}
+ */
+const useOverflowControl = (targetSelector) => {
+ useEffect(() => {
+ const handleOverflow = () => {
+ const body = document.querySelector('body');
+ const targetElement = document.querySelector(targetSelector);
+
+ body.style.overflow = targetElement ? 'hidden' : 'auto';
+ };
+
+ handleOverflow();
+
+ const observer = new MutationObserver(handleOverflow);
+ observer.observe(document.body, { attributes: true, childList: true, subtree: true });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [targetSelector]);
+};
+
+export default useOverflowControl;
diff --git a/src/generic/hooks/useOverflowControl.test.jsx b/src/generic/hooks/useOverflowControl.test.jsx
new file mode 100644
index 0000000000..77b69d8dcf
--- /dev/null
+++ b/src/generic/hooks/useOverflowControl.test.jsx
@@ -0,0 +1,53 @@
+import { renderHook } from '@testing-library/react-hooks';
+
+import useOverflowControl from './useOverflowControl';
+
+const observerInstance = {
+ observe: jest.fn(),
+ disconnect: jest.fn(),
+};
+
+let observerCallback = jest.fn();
+
+const mutationObserverMock = jest.fn((callback) => {
+ observerCallback = callback;
+ return observerInstance;
+});
+
+describe('useOverflowControl', () => {
+ const targetElement = document.createElement('div');
+ targetElement.className = 'target-element';
+ document.body.appendChild(targetElement);
+
+ beforeEach(() => {
+ global.MutationObserver = mutationObserverMock;
+ });
+
+ afterEach(() => delete global.MutationObserver);
+
+ it('should set body overflow to hidden when target element is present', () => {
+ const { unmount } = renderHook(() => useOverflowControl('.target-element'));
+
+ // Simulate the MutationObserver callback with added nodes
+ observerCallback([{ addedNodes: [targetElement] }]);
+ expect(document.body.style.overflow).toBe('hidden');
+
+ unmount();
+
+ document.body.style.overflow = 'auto';
+ expect(document.body.style.overflow).toBe('auto');
+ });
+
+ it('should set body overflow to auto when target element is not present', () => {
+ const { unmount } = renderHook(() => useOverflowControl('.non-existent-target-element'));
+
+ // Simulate the MutationObserver callback with added nodes
+ observerCallback([{ removedNodes: [document.querySelector('.non-existent-target-element')] }]);
+ expect(document.body.style.overflow).toBe('auto');
+
+ unmount();
+
+ document.body.style.overflow = 'auto';
+ expect(document.body.style.overflow).toBe('auto');
+ });
+});