feat: [FC-0044] Course unit - Drag and drop for xblocks (#908)

Implements drag and drop for xblocks in the unit page.
This commit is contained in:
Peter Kulko
2024-04-30 17:21:51 +03:00
committed by GitHub
parent e24fb7889e
commit a9a73efbb6
25 changed files with 252 additions and 55 deletions

View File

@@ -45,12 +45,12 @@ import HighlightsModal from './highlights-modal/HighlightsModal';
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
import PublishModal from './publish-modal/PublishModal';
import PageAlerts from './page-alerts/PageAlerts';
import DraggableList from './drag-helper/DraggableList';
import DraggableList from '../generic/drag-helper/DraggableList';
import {
canMoveSection,
possibleUnitMoves,
possibleSubsectionMoves,
} from './drag-helper/utils';
} from '../generic/drag-helper/utils';
import { useCourseOutline } from './hooks';
import messages from './messages';

View File

@@ -7,5 +7,4 @@
@import "./empty-placeholder/EmptyPlaceholder";
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./drag-helper/SortableItem";
@import "./xblock-status/XBlockStatus";

View File

@@ -56,7 +56,7 @@ import {
moveUnitOver,
moveSubsection,
moveUnit,
} from './drag-helper/utils';
} from '../generic/drag-helper/utils';
let axiosMock;
let store;

View File

@@ -13,8 +13,8 @@ import classNames from 'classnames';
import { setCurrentItem, setCurrentSection } from '../data/slice';
import { RequestStatus } from '../../data/constants';
import CardHeader from '../card-header/CardHeader';
import SortableItem from '../drag-helper/SortableItem';
import { DragContext } from '../drag-helper/DragContextProvider';
import SortableItem from '../../generic/drag-helper/SortableItem';
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';

View File

@@ -14,8 +14,8 @@ import { isEmpty } from 'lodash';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
import { RequestStatus } from '../../data/constants';
import CardHeader from '../card-header/CardHeader';
import SortableItem from '../drag-helper/SortableItem';
import { DragContext } from '../drag-helper/DragContextProvider';
import SortableItem from '../../generic/drag-helper/SortableItem';
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';

View File

@@ -9,7 +9,7 @@ import { useSearchParams } from 'react-router-dom';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
import { RequestStatus } from '../../data/constants';
import CardHeader from '../card-header/CardHeader';
import SortableItem from '../drag-helper/SortableItem';
import SortableItem from '../../generic/drag-helper/SortableItem';
import TitleLink from '../card-header/TitleLink';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';

View File

@@ -1,10 +1,12 @@
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 { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
import { ErrorAlert } from '@edx/frontend-lib-content-components';
import { DraggableList, ErrorAlert } from '@edx/frontend-lib-content-components';
import { Warning as WarningIcon } from '@openedx/paragon/icons';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
import SubHeader from '../generic/sub-header/SubHeader';
@@ -56,10 +58,20 @@ const CourseUnit = ({ courseId }) => {
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
handleXBlockDragAndDrop,
canPasteComponent,
} = useCourseUnit({ courseId, blockId });
document.title = getPageHeadTitle('', unitTitle);
const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]);
const [unitXBlocks, setUnitXBlocks] = useState(initialXBlocksData);
useEffect(() => {
document.title = getPageHeadTitle('', unitTitle);
}, [unitTitle]);
useEffect(() => {
setUnitXBlocks(courseVerticalChildren.children);
}, [courseVerticalChildren.children]);
const {
isShow: isShowProcessingNotification,
@@ -78,6 +90,12 @@ const CourseUnit = ({ courseId }) => {
);
}
const finalizeXBlockOrder = () => (newXBlocks) => {
handleXBlockDragAndDrop(newXBlocks.map(xBlock => xBlock.id), () => {
setUnitXBlocks(initialXBlocksData);
});
};
return (
<>
<Container size="xl" className="course-unit px-4">
@@ -122,6 +140,7 @@ const CourseUnit = ({ courseId }) => {
<Layout.Element>
{currentlyVisibleToStudents && (
<AlertMessage
className="course-unit__alert"
title={intl.formatMessage(messages.alertUnpublishedVersion)}
variant="warning"
icon={WarningIcon}
@@ -133,24 +152,36 @@ const CourseUnit = ({ courseId }) => {
courseId={courseId}
/>
)}
<Stack gap={4} className="mb-4">
{courseVerticalChildren.children.map(({
name, blockId: id, blockType: type, shouldScroll, userPartitionInfo, validationMessages,
}) => (
<CourseXBlock
id={id}
key={id}
title={name}
type={type}
blockId={blockId}
validationMessages={validationMessages}
shouldScroll={shouldScroll}
unitXBlockActions={unitXBlockActions}
handleConfigureSubmit={handleConfigureSubmit}
data-testid="course-xblock"
userPartitionInfo={userPartitionInfo}
/>
))}
<Stack className="mb-4 course-unit__xblocks">
<DraggableList
itemList={unitXBlocks}
setState={setUnitXBlocks}
updateOrder={finalizeXBlockOrder}
>
<SortableContext
id="root"
items={unitXBlocks}
strategy={verticalListSortingStrategy}
>
{unitXBlocks.map(({
name, id, blockType: type, shouldScroll, userPartitionInfo, validationMessages,
}) => (
<CourseXBlock
id={id}
key={id}
title={name}
type={type}
blockId={blockId}
validationMessages={validationMessages}
shouldScroll={shouldScroll}
handleConfigureSubmit={handleConfigureSubmit}
unitXBlockActions={unitXBlockActions}
data-testid="course-xblock"
userPartitionInfo={userPartitionInfo}
/>
))}
</SortableContext>
</DraggableList>
</Stack>
<AddComponent
blockId={blockId}

View File

@@ -4,3 +4,7 @@
@import "./course-xblock/CourseXBlock";
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";
.course-unit__alert {
margin-bottom: 1.75rem;
}

View File

@@ -54,6 +54,7 @@ import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
import messages from './messages';
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
import { RequestStatus } from '../data/constants';
let axiosMock;
let store;
@@ -1132,7 +1133,9 @@ describe('<CourseUnit />', () => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage }));
expect(getAllByTestId('course-xblock')).toHaveLength(2);
await waitFor(() => {
expect(getAllByTestId('course-xblock')).toHaveLength(2);
});
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
@@ -1219,9 +1222,11 @@ describe('<CourseUnit />', () => {
courseUnitMock,
]);
units = getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
expect(units).toHaveLength(courseUnits.length);
await waitFor(() => {
units = getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
expect(units).toHaveLength(courseUnits.length);
});
axiosMock
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
@@ -1442,4 +1447,56 @@ describe('<CourseUnit />', () => {
)).not.toBeInTheDocument();
});
});
describe('Drag and drop', () => {
it('checks xblock list is restored to original order when API call fails', async () => {
const { findAllByRole } = render(<RootWrapper />);
const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = xBlocksDraggers[1];
axiosMock
.onPut(getXBlockBaseApiUrl(blockId))
.reply(500, { dummy: 'value' });
const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseUnit.savingStatus;
expect(saveStatus).toEqual(RequestStatus.FAILED);
});
const xBlock1New = store.getState().courseUnit.courseVerticalChildren.children[0].id;
expect(xBlock1).toBe(xBlock1New);
});
it('check that new xblock list is saved when dragged', async () => {
const { findAllByRole } = render(<RootWrapper />);
const xBlocksDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = xBlocksDraggers[1];
axiosMock
.onPut(getXBlockBaseApiUrl(blockId))
.reply(200, { dummy: 'value' });
const xBlock1 = store.getState().courseUnit.courseVerticalChildren.children[0].id;
fireEvent.keyDown(draggableButton, { key: 'ArrowUp' });
await waitFor(async () => {
fireEvent.keyDown(draggableButton, { code: 'Space' });
const saveStatus = store.getState().courseUnit.savingStatus;
expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
});
const xBlock2 = store.getState().courseUnit.courseVerticalChildren.children[1].id;
expect(xBlock1).toBe(xBlock2);
});
});
});

View File

@@ -34,6 +34,7 @@ const PastNotificationAlert = ({ staticFileNotices, courseId }) => {
{hasConflictingErrors && (
<AlertMessage
data-testid="has-conflicting-errors-alert"
className="course-unit__alert"
title={intl.formatMessage(messages.hasConflictingErrorsTitle)}
onClose={() => handleCloseNotificationAlert('conflictingFilesAlert')}
description={(
@@ -56,6 +57,7 @@ const PastNotificationAlert = ({ staticFileNotices, courseId }) => {
{hasErrorFiles && (
<AlertMessage
data-testid="has-error-files-alert"
className="course-unit__alert"
title={intl.formatMessage(messages.hasErrorsTitle)}
onClose={() => handleCloseNotificationAlert('errorFilesAlert')}
description={(
@@ -72,6 +74,7 @@ const PastNotificationAlert = ({ staticFileNotices, courseId }) => {
{hasNewFiles && (
<AlertMessage
data-testid="has-new-files-alert"
className="course-unit__alert"
title={intl.formatMessage(messages.hasNewFilesTitle)}
onClose={() => handleCloseNotificationAlert('newFilesAlert')}
description={(
@@ -97,11 +100,16 @@ const PastNotificationAlert = ({ staticFileNotices, courseId }) => {
PastNotificationAlert.propTypes = {
courseId: PropTypes.string.isRequired,
staticFileNotices: PropTypes.shape({
conflictingFiles: PropTypes.arrayOf(PropTypes.string),
errorFiles: PropTypes.arrayOf(PropTypes.string),
newFiles: PropTypes.arrayOf(PropTypes.string),
}).isRequired,
staticFileNotices:
PropTypes.objectOf({
conflictingFiles: PropTypes.arrayOf(PropTypes.string),
errorFiles: PropTypes.arrayOf(PropTypes.string),
newFiles: PropTypes.arrayOf(PropTypes.string),
}),
};
PastNotificationAlert.defaultProps = {
staticFileNotices: {},
};
export default PastNotificationAlert;

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import {
ActionRow, Card, Dropdown, Icon, IconButton, useToggle,
@@ -11,6 +12,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
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';
@@ -77,18 +79,25 @@ const CourseXBlock = ({
<div
ref={courseXBlockElementRef}
{...props}
className={isScrolledToElement ? 'xblock-highlight' : undefined}
className={classNames('course-unit__xblock', {
'xblock-highlight': isScrolledToElement,
})}
>
<Card className="mb-1">
<Card
as={SortableItem}
id={id}
draggable
category="xblock"
componentStyle={{ marginBottom: 0 }}
>
<Card.Header
title={title}
subtitle={visibilityMessage}
actions={(
<ActionRow>
<ActionRow className="mr-2">
<IconButton
alt={intl.formatMessage(messages.blockAltButtonEdit)}
iconAs={EditIcon}
size="md"
onClick={handleEdit}
/>
<Dropdown>
@@ -97,7 +106,6 @@ const CourseXBlock = ({
as={IconButton}
src={MoveVertIcon}
alt={intl.formatMessage(messages.blockActionsDropdownAlt)}
size="sm"
iconAs={Icon}
/>
<Dropdown.Menu>
@@ -135,7 +143,6 @@ const CourseXBlock = ({
/>
</ActionRow>
)}
size="md"
/>
<Card.Section>
<XBlockMessages validationMessages={validationMessages} />

View File

@@ -1,5 +1,9 @@
.course-unit {
.course-unit__xblocks {
.course-unit__xblock:not(:first-child) {
margin-top: 1.75rem;
}
.pgn__card-header {
display: flex;
justify-content: space-between;

View File

@@ -3,7 +3,7 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
import { normalizeCourseSectionVerticalData } from './utils';
import { normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -116,8 +116,9 @@ export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible,
export async function getCourseVerticalChildren(itemId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseVerticalChildrenApiUrl(itemId));
const camelCaseData = camelCaseObject(data);
return camelCaseObject(data);
return updateXBlockBlockIdToId(camelCaseData);
}
/**
@@ -147,3 +148,16 @@ export async function duplicateUnitItem(itemId, XBlockId) {
return data;
}
/**
* Sets the order list of XBlocks.
* @param {string} blockId - The identifier of the course unit.
* @param {Object[]} children - The array of child elements representing the updated order of XBlocks.
* @returns {Promise<Object>} - A promise that resolves to the updated data after setting the XBlock order.
*/
export async function setXBlockOrderList(blockId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getXBlockBaseApiUrl(blockId), { children });
return data;
}

View File

@@ -1,13 +1,11 @@
import { createSelector } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
import { RequestStatus } from 'CourseAuthoring/data/constants';
export const getCourseUnitData = (state) => state.courseUnit.unit;
export const getCanEdit = (state) => state.courseUnit.canEdit;
export const getStaticFileNotices = (state) => state.courseUnit.staticFileNotices;
export const getCourseUnit = (state) => state.courseUnit;
export const getSavingStatus = (state) => state.courseUnit.savingStatus;
export const getLoadingStatus = (state) => state.courseUnit.loadingStatus;
export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus;
export const getSequenceIds = (state) => state.courseUnit.courseSectionVertical.courseSequenceIds;
export const getCourseSectionVertical = (state) => state.courseUnit.courseSectionVertical;

View File

@@ -83,7 +83,7 @@ const slice = createSlice({
},
deleteXBlock: (state, { payload }) => {
state.courseVerticalChildren.children = state.courseVerticalChildren.children.filter(
(component) => component.blockId !== payload,
(component) => component.id !== payload,
);
},
duplicateXBlock: (state, { payload }) => {
@@ -100,6 +100,14 @@ const slice = createSlice({
fetchStaticFileNoticesSuccess: (state, { payload }) => {
state.staticFileNotices = payload;
},
reorderXBlockList: (state, { payload }) => {
// Create a map for payload IDs to their index for O(1) lookups
const indexMap = new Map(payload.map((id, index) => [id, index]));
// Directly sort the children based on the order defined in payload
// This avoids the need to copy the array beforehand
state.courseVerticalChildren.children.sort((a, b) => (indexMap.get(a.id) || 0) - (indexMap.get(b.id) || 0));
},
},
});
@@ -121,6 +129,7 @@ export const {
deleteXBlock,
duplicateXBlock,
fetchStaticFileNoticesSuccess,
reorderXBlockList,
} = slice.actions;
export const {

View File

@@ -17,6 +17,7 @@ import {
handleCourseUnitVisibilityAndData,
deleteUnitItem,
duplicateUnitItem,
setXBlockOrderList,
} from './api';
import {
updateLoadingCourseUnitStatus,
@@ -34,6 +35,7 @@ import {
deleteXBlock,
duplicateXBlock,
fetchStaticFileNoticesSuccess,
reorderXBlockList,
} from './slice';
import { getNotificationMessage } from './utils';
@@ -246,3 +248,26 @@ export function duplicateUnitItemQuery(itemId, xblockId) {
}
};
}
export function setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setXBlockOrderList(blockId, xblockListIds).then(async (result) => {
if (result) {
dispatch(reorderXBlockList(xblockListIds));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
const courseUnit = await getCourseUnitData(blockId);
dispatch(fetchCourseItemSuccess(courseUnit));
}
});
} catch (error) {
restoreCallback();
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
} finally {
dispatch(hideProcessingNotification());
}
};
}

View File

@@ -50,3 +50,38 @@ export const getNotificationMessage = (type, isVisible, isModalView) => {
return notificationMessage;
};
/**
* Updates the 'id' property of objects in the data structure using the 'blockId' value where present.
* @param {Object} data - The original data structure to be updated.
* @returns {Object} - The updated data structure with updated 'id' values.
*/
export const updateXBlockBlockIdToId = (data) => {
if (typeof data !== 'object' || data === null) {
return data;
}
if (Array.isArray(data)) {
return data.map(updateXBlockBlockIdToId);
}
const updatedData = {};
Object.keys(data).forEach(key => {
const value = data[key];
if (key === 'children' || key === 'selectablePartitions' || key === 'groups') {
updatedData[key] = updateXBlockBlockIdToId(value);
} else {
// Copy other properties unchanged
updatedData[key] = value;
}
});
// Special handling for objects with both 'id' and 'blockId' to ensure 'blockId' takes precedence
if ('blockId' in data) {
updatedData.id = data.blockId;
}
return updatedData;
};

View File

@@ -11,13 +11,14 @@ import {
fetchCourseVerticalChildrenData,
deleteUnitItemQuery,
duplicateUnitItemQuery,
setXBlockOrderListQuery,
editCourseUnitVisibilityAndData,
} from './data/thunk';
import {
getCourseSectionVertical,
getCourseVerticalChildren,
getCourseUnitData,
getLoadingStatus,
getIsLoading,
getSavingStatus,
getSequenceStatus,
getStaticFileNotices,
@@ -37,7 +38,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const [hasInternetConnectionError, setInternetConnectionError] = useState(false);
const courseUnit = useSelector(getCourseUnitData);
const savingStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);
const isLoading = useSelector(getIsLoading);
const sequenceStatus = useSelector(getSequenceStatus);
const { draftPreviewLink, publishedPreviewLink } = useSelector(getCourseSectionVertical);
const courseVerticalChildren = useSelector(getCourseVerticalChildren);
@@ -111,6 +112,10 @@ export const useCourseUnit = ({ courseId, blockId }) => {
},
};
const handleXBlockDragAndDrop = (xblockListIds, restoreCallback) => {
dispatch(setXBlockOrderListQuery(blockId, xblockListIds, restoreCallback));
};
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
dispatch(updateQueryPendingStatus(true));
@@ -137,8 +142,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
isErrorAlert,
staticFileNotices,
currentlyVisibleToStudents,
isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS
|| loadingStatus.courseSectionVerticalLoadingStatus === RequestStatus.IN_PROGRESS,
isLoading,
isTitleEditFormOpen,
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
sharedClipboardData,
@@ -152,6 +156,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
handleXBlockDragAndDrop,
canPasteComponent,
};
};

View File

@@ -16,7 +16,7 @@ import {
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import DragContextProvider from './DragContextProvider';
import { COURSE_BLOCK_NAMES } from '../constants';
import { COURSE_BLOCK_NAMES } from '../../constants';
import {
moveSubsectionOver,
moveUnitOver,

View File

@@ -11,3 +11,4 @@
@import "./tag-count/TagCount";
@import "./modal-dropzone/ModalDropzone";
@import "./configure-modal/ConfigureModal";
@import "./drag-helper/SortableItem";