diff --git a/package-lock.json b/package-lock.json
index 107c6f5812..7f8f7b11ac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
@@ -4997,6 +5000,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",
@@ -11448,6 +11466,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",
@@ -16223,6 +16259,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",
@@ -23325,6 +23366,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",
diff --git a/package.json b/package.json
index f6df2ec00b..75660e9ed9 100644
--- a/package.json
+++ b/package.json
@@ -55,12 +55,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",
diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx
index eabe680764..abc88251a5 100644
--- a/src/course-outline/CourseOutline.jsx
+++ b/src/course-outline/CourseOutline.jsx
@@ -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,51 @@ 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, dragElement) => {
+ setSections((prevSections) => {
+ const newList = update(prevSections, {
+ $splice: [
+ [dragIndex, 1],
+ [hoverIndex, 0, prevSections[dragIndex]],
+ ],
+ });
+ // set listRef to element that was dragged to make sure scrolling
+ // uses the correct element while calculating visibility.
+ listRef.current = dragElement;
+ return newList;
+ });
+ }, []);
+
+ const finalizeSectionOrder = () => {
+ handleDragNDrop(sections.map((section) => section.id), () => {
+ setSections(() => initialSections);
+ });
+ };
+
+ useEffect(() => {
+ setSections(sectionsList);
+ }, [sectionsList]);
+
+ useEffect(() => {
+ // scrollToElement called in another useEffect to make sure sections are rendered first.
+ if (listRef.current) {
+ scrollToElement(listRef.current);
+ }
+ }, [sections]);
+
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>>;
@@ -150,38 +189,45 @@ const CourseOutline = ({ courseId }) => {
openEnableHighlightsModal={openEnableHighlightsModal}
/>
- {sectionsList.length ? (
+ {sections.length ? (
<>
- {sectionsList.map((section) => (
-
- {section.childInfo.children.map((subsection) => (
-
- ))}
-
- ))}
+
+ {sections.map((section, index) => (
+
+ {section.childInfo.children.map((subsection) => (
+
+ ))}
+
+ ))}
+
', () => {
it('adds new section correctly', async () => {
const { findAllByTestId } = render(
);
let element = await findAllByTestId('section-card');
+ window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
+ top: 0,
+ bottom: 4000,
+ }));
expect(element.length).toBe(4);
axiosMock
@@ -146,6 +151,10 @@ describe('
', () => {
const [section] = await findAllByTestId('section-card');
let subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(1);
+ window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
+ top: 0,
+ bottom: 4000,
+ }));
axiosMock
.onPost(getXBlockBaseApiUrl())
@@ -159,6 +168,7 @@ describe('
', () => {
subsections = await within(section).findAllByTestId('subsection-card');
expect(subsections.length).toBe(2);
+ expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
});
it('render error alert after failed reindex correctly', async () => {
@@ -488,4 +498,47 @@ describe('
', () => {
expect(getByRole('button', { name: '5 Section highlights' })).toBeInTheDocument();
});
+
+ it('check section list is ordered successfully', async () => {
+ const { getAllByTestId } = render(
);
+ 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 list is restored to original order when API call fails', async () => {
+ const { getAllByTestId } = render(
);
+ 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);
+ }
+ });
+ });
});
diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js
index 5714fa9ada..702715ce8f 100644
--- a/src/course-outline/data/api.js
+++ b/src/course-outline/data/api.js
@@ -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
} children list of sections id's
+ * @returns {Promise