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) => ( + + ))} + + ))} +