Skip to content

Commit

Permalink
feat: [FC-0044] Course unit - Drag and drop for xblocks (#908)
Browse files Browse the repository at this point in the history
Implements drag and drop for xblocks in the unit page.
  • Loading branch information
PKulkoRaccoonGang committed Apr 30, 2024
1 parent e24fb78 commit a9a73ef
Show file tree
Hide file tree
Showing 25 changed files with 252 additions and 55 deletions.
4 changes: 2 additions & 2 deletions src/course-outline/CourseOutline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 0 additions & 1 deletion src/course-outline/CourseOutline.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@
@import "./empty-placeholder/EmptyPlaceholder";
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./drag-helper/SortableItem";
@import "./xblock-status/XBlockStatus";
2 changes: 1 addition & 1 deletion src/course-outline/CourseOutline.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
moveUnitOver,
moveSubsection,
moveUnit,
} from './drag-helper/utils';
} from '../generic/drag-helper/utils';

let axiosMock;
let store;
Expand Down
4 changes: 2 additions & 2 deletions src/course-outline/section-card/SectionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions src/course-outline/subsection-card/SubsectionCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/course-outline/unit-card/UnitCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
71 changes: 51 additions & 20 deletions src/course-unit/CourseUnit.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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">
Expand Down Expand Up @@ -122,6 +140,7 @@ const CourseUnit = ({ courseId }) => {
<Layout.Element>
{currentlyVisibleToStudents && (
<AlertMessage
className="course-unit__alert"
title={intl.formatMessage(messages.alertUnpublishedVersion)}
variant="warning"
icon={WarningIcon}
Expand All @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions src/course-unit/CourseUnit.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
@import "./course-xblock/CourseXBlock";
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";

.course-unit__alert {
margin-bottom: 1.75rem;
}
65 changes: 61 additions & 4 deletions src/course-unit/CourseUnit.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
});
});
});
18 changes: 13 additions & 5 deletions src/course-unit/clipboard/paste-notification/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={(
Expand All @@ -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={(
Expand All @@ -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={(
Expand All @@ -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;
19 changes: 13 additions & 6 deletions src/course-unit/course-xblock/CourseXBlock.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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>
Expand All @@ -97,7 +106,6 @@ const CourseXBlock = ({
as={IconButton}
src={MoveVertIcon}
alt={intl.formatMessage(messages.blockActionsDropdownAlt)}
size="sm"
iconAs={Icon}
/>
<Dropdown.Menu>
Expand Down Expand Up @@ -135,7 +143,6 @@ const CourseXBlock = ({
/>
</ActionRow>
)}
size="md"
/>
<Card.Section>
<XBlockMessages validationMessages={validationMessages} />
Expand Down
4 changes: 4 additions & 0 deletions src/course-unit/course-xblock/CourseXBlock.scss
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading

0 comments on commit a9a73ef

Please sign in to comment.