From 48ab324100a36a9b2ba09f393ab6bb1d17a57198 Mon Sep 17 00:00:00 2001 From: Stephannie Jimenez Date: Mon, 4 Dec 2023 18:45:47 -0500 Subject: [PATCH] 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 --- package-lock.json | 78 ++++++++ package.json | 3 + src/course-outline/CourseOutline.jsx | 113 +++++++---- src/course-outline/CourseOutline.test.jsx | 44 ++++ src/course-outline/data/api.js | 15 ++ src/course-outline/data/slice.js | 7 + src/course-outline/data/thunk.js | 23 +++ src/course-outline/hooks.jsx | 6 + .../section-card/SectionCard.jsx | 188 +++++++++++++----- .../section-card/SectionCard.test.jsx | 39 ++-- src/course-outline/section-card/itemTypes.js | 5 + 11 files changed, 418 insertions(+), 103 deletions(-) create mode 100644 src/course-outline/section-card/itemTypes.js diff --git a/package-lock.json b/package-lock.json index acfbbe203..a2016800a 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", @@ -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", diff --git a/package.json b/package.json index bc8d5d3c7..0814e16de 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index eabe68076..44726ed4a 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,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} />
- {sectionsList.length ? ( + {sections.length ? ( <> - {sectionsList.map((section) => ( - - {section.childInfo.children.map((subsection) => ( - - ))} - - ))} + + {sections.map((section, index) => ( + + {section.childInfo.children.map((subsection) => ( + + ))} + + ))} +