Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading