feat: add drag n drop functionality to section cards
feat: use react-dnd library for drag and drop implementation style: fix linting issues fix: finalize section order on drop instead of hover fix: prevent same index drag to start request and restore state on error fix: restore sectionlist order fix: prevent drag event while editing the text style: fix linting issues test: fix failing tests test: add missing hooks to SectionCard component in test test: add wrapping to SectionCard test component test: add tests for checking the API that sets the ordering fix: merge scroll-to-bottom with drag and drop implementations fix: fix linting issues
This commit is contained in:
committed by
Kristin Aoki
parent
f79bebceeb
commit
48ab324100
78
package-lock.json
generated
78
package-lock.json
generated
@@ -29,12 +29,15 @@
|
||||
"email-validator": "2.0.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.2.6",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"moment": "2.29.4",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dnd": "14.0.5",
|
||||
"react-dnd-html5-backend": "14.1.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
@@ -4999,6 +5002,21 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
|
||||
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="
|
||||
},
|
||||
"node_modules/@react-dnd/invariant": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
|
||||
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="
|
||||
},
|
||||
"node_modules/@react-dnd/shallowequal": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
|
||||
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz",
|
||||
@@ -11480,6 +11498,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dnd-core": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
|
||||
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
|
||||
"dependencies": {
|
||||
"@react-dnd/asap": "^4.0.0",
|
||||
"@react-dnd/invariant": "^2.0.0",
|
||||
"redux": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dnd-core/node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dns-equal": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz",
|
||||
@@ -16255,6 +16291,11 @@
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/immutability-helper": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz",
|
||||
"integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ=="
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz",
|
||||
@@ -23357,6 +23398,43 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "14.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
|
||||
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^2.0.0",
|
||||
"@react-dnd/shallowequal": "^2.0.0",
|
||||
"dnd-core": "14.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||
"@types/node": ">= 12",
|
||||
"@types/react": ">= 16",
|
||||
"react": ">= 16.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/hoist-non-react-statics": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
|
||||
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
|
||||
"dependencies": {
|
||||
"dnd-core": "14.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
|
||||
|
||||
@@ -56,12 +56,15 @@
|
||||
"email-validator": "2.0.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.2.6",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"moment": "2.29.4",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-datepicker": "^4.13.0",
|
||||
"react-dnd": "14.0.5",
|
||||
"react-dnd-html5-backend": "14.1.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { React, useEffect, useRef } from 'react';
|
||||
import {
|
||||
React, useState, useCallback, useEffect, useRef,
|
||||
} from 'react';
|
||||
import update from 'immutability-helper';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
@@ -80,17 +85,48 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleNewSectionSubmit,
|
||||
handleNewSubsectionSubmit,
|
||||
handleDragNDrop,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
useEffect(() => {
|
||||
scrollToElement(listRef);
|
||||
}, [sectionsList]);
|
||||
document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle));
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
|
||||
const initialSections = [...sectionsList];
|
||||
|
||||
const {
|
||||
isShow: isShowProcessingNotification,
|
||||
title: processingNotificationTitle,
|
||||
} = useSelector(getProcessingNotification);
|
||||
|
||||
const moveSection = useCallback((dragIndex, hoverIndex) => {
|
||||
setSections((prevSections) => {
|
||||
const newList = update(prevSections, {
|
||||
$splice: [
|
||||
[dragIndex, 1],
|
||||
[hoverIndex, 0, prevSections[dragIndex]],
|
||||
],
|
||||
});
|
||||
return newList;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const finalizeSectionOrder = () => {
|
||||
handleDragNDrop(sections.map((section) => section.id), () => {
|
||||
setSections(() => initialSections);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsList) {
|
||||
setSections((prevSections) => {
|
||||
if (prevSections.length < sectionsList.length) {
|
||||
scrollToElement(listRef);
|
||||
}
|
||||
return sectionsList;
|
||||
});
|
||||
}
|
||||
}, [sectionsList]);
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
@@ -150,38 +186,45 @@ const CourseOutline = ({ courseId }) => {
|
||||
openEnableHighlightsModal={openEnableHighlightsModal}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
{sectionsList.length ? (
|
||||
{sections.length ? (
|
||||
<>
|
||||
{sectionsList.map((section) => (
|
||||
<SectionCard
|
||||
key={section.id}
|
||||
section={section}
|
||||
savingStatus={savingStatus}
|
||||
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
ref={listRef}
|
||||
>
|
||||
{section.childInfo.children.map((subsection) => (
|
||||
<SubsectionCard
|
||||
key={subsection.id}
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
ref={listRef}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
))}
|
||||
<DndProvider
|
||||
backend={HTML5Backend}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<SectionCard
|
||||
key={section.id}
|
||||
index={index}
|
||||
section={section}
|
||||
savingStatus={savingStatus}
|
||||
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
moveSection={moveSection}
|
||||
finalizeSectionOrder={finalizeSectionOrder}
|
||||
ref={listRef}
|
||||
>
|
||||
{section.childInfo.children.map((subsection) => (
|
||||
<SubsectionCard
|
||||
key={subsection.id}
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
ref={listRef}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
))}
|
||||
</DndProvider>
|
||||
<Button
|
||||
data-testid="new-section-button"
|
||||
className="mt-4"
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
fetchCourseSectionQuery,
|
||||
publishCourseItemQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
setSectionOrderListQuery,
|
||||
} from './data/thunk';
|
||||
import initializeStore from '../store';
|
||||
import {
|
||||
@@ -488,4 +489,47 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check section order list when set section order query is successful', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
let { children } = courseOutlineIndexMock.courseStructure.childInfo;
|
||||
children = children.splice(2, 0, children.splice(0, 1)[0]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), children)
|
||||
.reply(200);
|
||||
|
||||
await executeThunk(setSectionOrderListQuery(courseBlockId, children, () => {}), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAllByTestId('section-card')).toHaveLength(4);
|
||||
const newSections = getAllByTestId('section-card');
|
||||
for (let i; i < children.length; i++) {
|
||||
expect(children[i].id === newSections[i].id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('check section order list when set section order query is unsuccessful', async () => {
|
||||
const { getAllByTestId } = render(<RootWrapper />);
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const { children } = courseOutlineIndexMock.courseStructure.childInfo;
|
||||
const newChildren = children.splice(2, 0, children.splice(0, 1)[0]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), undefined)
|
||||
.reply(500);
|
||||
|
||||
await executeThunk(setSectionOrderListQuery(courseBlockId, undefined, () => children), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAllByTestId('section-card')).toHaveLength(4);
|
||||
const newSections = getAllByTestId('section-card');
|
||||
for (let i; i < children.length; i++) {
|
||||
expect(children[i].id === newSections[i].id);
|
||||
expect(newChildren[i].id !== newSections[i].id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,3 +296,18 @@ export async function addNewCourseItem(parentLocator, category, displayName) {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set order for the list of the sections
|
||||
* @param {string} courseId
|
||||
* @param {Array<string>} children list of sections id's
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setSectionOrderList(courseId, children) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(getEnableHighlightsEmailsApiUrl(courseId), {
|
||||
children,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ const slice = createSlice({
|
||||
setCurrentItem: (state, { payload }) => {
|
||||
state.currentItem = payload;
|
||||
},
|
||||
reorderSectionList: (state, { payload }) => {
|
||||
const sectionsList = [...state.sectionsList];
|
||||
sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id));
|
||||
|
||||
state.sectionsList = [...sectionsList];
|
||||
},
|
||||
setCurrentSection: (state, { payload }) => {
|
||||
state.currentSection = payload;
|
||||
},
|
||||
@@ -162,6 +168,7 @@ export const {
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
configureCourseSection,
|
||||
restartIndexingOnCourse,
|
||||
updateCourseSectionHighlights,
|
||||
setSectionOrderList,
|
||||
} from './api';
|
||||
import {
|
||||
addSection,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
@@ -375,3 +377,24 @@ export function addNewSubsectionQuery(parentLocator) {
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function setSectionOrderListQuery(courseId, newListId, restoreCallback) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await setSectionOrderList(courseId, newListId).then(async (result) => {
|
||||
if (result) {
|
||||
dispatch(reorderSectionList(newListId));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
restoreCallback();
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
publishCourseItemQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
configureCourseSectionQuery,
|
||||
setSectionOrderListQuery,
|
||||
} from './data/thunk';
|
||||
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
@@ -152,6 +153,10 @@ const useCourseOutline = ({ courseId }) => {
|
||||
dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id));
|
||||
};
|
||||
|
||||
const handleDragNDrop = (newListId, restoreCallback) => {
|
||||
dispatch(setSectionOrderListQuery(courseId, newListId, restoreCallback));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseOutlineIndexQuery(courseId));
|
||||
dispatch(fetchCourseBestPracticesQuery({ courseId }));
|
||||
@@ -207,6 +212,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleNewSectionSubmit,
|
||||
handleNewSubsectionSubmit,
|
||||
handleDragNDrop,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useState,
|
||||
import React, {
|
||||
forwardRef, useEffect, useState, useRef,
|
||||
} from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -14,9 +13,11 @@ import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import { getItemStatus } from '../utils';
|
||||
import messages from './messages';
|
||||
import ItemTypes from './itemTypes';
|
||||
|
||||
const SectionCard = forwardRef(({
|
||||
section,
|
||||
index,
|
||||
children,
|
||||
onOpenHighlightsModal,
|
||||
onOpenPublishModal,
|
||||
@@ -27,6 +28,8 @@ const SectionCard = forwardRef(({
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onNewSubsectionSubmit,
|
||||
moveSection,
|
||||
finalizeSectionOrder,
|
||||
}, lastItemRef) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
@@ -49,6 +52,73 @@ const SectionCard = forwardRef(({
|
||||
highlights,
|
||||
} = section;
|
||||
|
||||
const moveRef = useRef(null);
|
||||
|
||||
const [{ handlerId }, drop] = useDrop({
|
||||
accept: ItemTypes.SECTION,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!moveRef.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = moveRef.current?.getBoundingClientRect();
|
||||
// Get vertical middle
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
// Get pixels to the top
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
// Only perform the move when the mouse has crossed half of the items height
|
||||
// When dragging downwards, only move when the cursor is below 50%
|
||||
// When dragging upwards, only move when the cursor is above 50%
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
// Time to actually perform the action
|
||||
moveSection(dragIndex, hoverIndex);
|
||||
// Note: we're mutating the monitor item here!
|
||||
// Generally it's better to avoid mutations,
|
||||
// but it's good here for the sake of performance
|
||||
// to avoid expensive index searches.
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const indexCopy = index;
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.SECTION,
|
||||
item: () => ({ id, index, startingIndex: indexCopy }),
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => !isFormOpen,
|
||||
end: (item) => {
|
||||
const { startingIndex } = item;
|
||||
if (index !== startingIndex) {
|
||||
finalizeSectionOrder();
|
||||
}
|
||||
},
|
||||
});
|
||||
const opacity = isDragging ? 0 : 1;
|
||||
drag(drop(moveRef));
|
||||
|
||||
const sectionStatus = getItemStatus({
|
||||
published,
|
||||
releasedToStudents,
|
||||
@@ -91,56 +161,65 @@ const SectionCard = forwardRef(({
|
||||
}, [savingStatus]);
|
||||
|
||||
return (
|
||||
<div className="section-card" data-testid="section-card" ref={lastItemRef}>
|
||||
<CardHeader
|
||||
sectionId={id}
|
||||
title={displayName}
|
||||
status={sectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
isExpanded={isExpanded}
|
||||
onExpand={handleExpandContent}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
namePrefix="section"
|
||||
/>
|
||||
<div className="section-card__content" data-testid="section-card__content">
|
||||
<div className="outline-section__status">
|
||||
<Button
|
||||
className="section-card__highlights"
|
||||
data-destid="section-card-highlights-button"
|
||||
variant="tertiary"
|
||||
onClick={handleOpenHighlightsModal}
|
||||
>
|
||||
<Badge className="highlights-badge">{highlights.length}</Badge>
|
||||
<p className="m-0 text-black">{messages.sectionHighlightsBadge.defaultMessage}</p>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div data-testid="section-card__subsections" className="section-card__subsections">
|
||||
{children}
|
||||
<div
|
||||
className="section-card"
|
||||
data-testid="section-card"
|
||||
data-handler-id={handlerId}
|
||||
style={{ opacity }}
|
||||
ref={moveRef}
|
||||
>
|
||||
<div ref={lastItemRef}>
|
||||
<CardHeader
|
||||
sectionId={id}
|
||||
title={displayName}
|
||||
status={sectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
isExpanded={isExpanded}
|
||||
onExpand={handleExpandContent}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
namePrefix="section"
|
||||
className="nodrag"
|
||||
/>
|
||||
<div className="section-card__content" data-testid="section-card__content">
|
||||
<div className="outline-section__status">
|
||||
<Button
|
||||
className="section-card__highlights"
|
||||
data-destid="section-card-highlights-button"
|
||||
variant="tertiary"
|
||||
onClick={handleOpenHighlightsModal}
|
||||
>
|
||||
<Badge className="highlights-badge">{highlights.length}</Badge>
|
||||
<p className="m-0 text-black">{messages.sectionHighlightsBadge.defaultMessage}</p>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="new-subsection-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewSubsectionSubmit}
|
||||
>
|
||||
{intl.formatMessage(messages.newSubsectionButton)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div data-testid="section-card__subsections" className="section-card__subsections">
|
||||
{children}
|
||||
</div>
|
||||
<Button
|
||||
data-testid="new-subsection-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewSubsectionSubmit}
|
||||
>
|
||||
{intl.formatMessage(messages.newSubsectionButton)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -161,6 +240,7 @@ SectionCard.propTypes = {
|
||||
staffOnlyMessage: PropTypes.bool.isRequired,
|
||||
highlights: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
}).isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
children: PropTypes.node,
|
||||
onOpenHighlightsModal: PropTypes.func.isRequired,
|
||||
onOpenPublishModal: PropTypes.func.isRequired,
|
||||
@@ -171,6 +251,8 @@ SectionCard.propTypes = {
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
isSectionsExpanded: PropTypes.bool.isRequired,
|
||||
onNewSubsectionSubmit: PropTypes.func.isRequired,
|
||||
moveSection: PropTypes.func.isRequired,
|
||||
finalizeSectionOrder: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SectionCard;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -28,21 +30,28 @@ const section = {
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<SectionCard
|
||||
section={section}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onEditClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
namePrefix="section"
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SectionCard>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<SectionCard
|
||||
section={section}
|
||||
index={0}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onEditClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
namePrefix="section"
|
||||
moveSection={jest.fn()}
|
||||
finalizeSectionOrder={jest.fn()}
|
||||
connectDragSource={(el) => el}
|
||||
isDragging
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SectionCard>
|
||||
</DndProvider>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
5
src/course-outline/section-card/itemTypes.js
Normal file
5
src/course-outline/section-card/itemTypes.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const ItemTypes = {
|
||||
SECTION: 'section',
|
||||
};
|
||||
|
||||
export default ItemTypes;
|
||||
Reference in New Issue
Block a user