From c14f0cb8c71f3b73ff49945ef075933393c11d9b Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Sun, 28 Nov 2021 21:41:29 +0300 Subject: [PATCH] feat: added draggable virualized lessons list --- package-lock.json | 111 ++++++- package.json | 4 +- src/helpers/index.ts | 15 + src/hooks/useDidMountEffect.ts | 15 + src/hooks/useFetchCourseQuery.ts | 12 +- .../edit/components/LessonList/LessonList.tsx | 270 +++++++++++------- src/pages/courses/edit/edit.tsx | 4 +- src/services/LessonService.ts | 23 ++ src/stores/RootStore.ts | 5 + 9 files changed, 345 insertions(+), 114 deletions(-) create mode 100644 src/hooks/useDidMountEffect.ts create mode 100644 src/services/LessonService.ts diff --git a/package-lock.json b/package-lock.json index 104260e..41e8227 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1099,6 +1099,15 @@ "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==", "dev": true }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/js-cookie": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", @@ -1150,6 +1159,15 @@ "csstype": "^3.0.2" } }, + "@types/react-beautiful-dnd": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz", + "integrity": "sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-dom": { "version": "17.0.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz", @@ -1168,6 +1186,17 @@ "@types/react": "*" } }, + "@types/react-redux": { + "version": "7.1.20", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz", + "integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "@types/react-router": { "version": "5.1.17", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.17.tgz", @@ -1205,6 +1234,19 @@ "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=" }, + "@virtuoso.dev/react-urx": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@virtuoso.dev/react-urx/-/react-urx-0.2.8.tgz", + "integrity": "sha512-QV9ZJ9Yla88ySgN+DuHLDYoQa0UDRrEq88sLqQESZUWzLughrkpg1BLmQT7FuiU2M78A+rQ9DR3GBlWrJIx+Bw==", + "requires": { + "@virtuoso.dev/urx": "^0.2.8" + } + }, + "@virtuoso.dev/urx": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@virtuoso.dev/urx/-/urx-0.2.8.tgz", + "integrity": "sha512-40uiTn5z1J/7at9Ao/aesyFhE7sS1cpb2MrbP/JSS6dcLMsQJcAZGedCY3bKkyjANH9DX/ciUpkpmT3TXmUBOQ==" + }, "@vitejs/plugin-react": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-1.0.4.tgz", @@ -1836,6 +1878,11 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, + "memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "mini-create-react-context": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", @@ -2019,6 +2066,11 @@ "side-channel": "^1.0.4" } }, + "raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "react": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", @@ -2028,6 +2080,20 @@ "object-assign": "^4.1.1" } }, + "react-beautiful-dnd": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", + "integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==", + "requires": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + } + }, "react-clientside-effect": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.5.tgz", @@ -2101,10 +2167,25 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "react-movable": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/react-movable/-/react-movable-3.0.2.tgz", - "integrity": "sha512-dDDYm3CRnDy8YLXMyyaR2MbcQiTwhPOP+dfl3fZukiI6mN1flVatcjSozT7HXjVk2yHwBC67ZOWGVAmjY6F/dA==" + "react-redux": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", + "integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + } + } }, "react-refresh": { "version": "0.10.0", @@ -2208,6 +2289,23 @@ } } }, + "react-virtuoso": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-2.2.8.tgz", + "integrity": "sha512-DArJ29ZrhdygdLKdXs/GB3VNDxaAF7YjdgIjlWPuK6UsV848hNmtw9px3LPdcQBErxaz7XCtHNfc7yInX+/M7g==", + "requires": { + "@virtuoso.dev/react-urx": "^0.2.8", + "@virtuoso.dev/urx": "^0.2.8" + } + }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerator-runtime": { "version": "0.13.9", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", @@ -2464,6 +2562,11 @@ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.5.tgz", "integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==" }, + "use-memo-one": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz", + "integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==" + }, "use-sidecar": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz", diff --git a/package.json b/package.json index 84df647..a7d06b4 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,15 @@ "moment": "^2.29.1", "qs": "^6.10.1", "react": "^17.0.0", + "react-beautiful-dnd": "^13.1.0", "react-dom": "^17.0.0", "react-error-boundary": "^3.1.3", "react-helmet": "^6.1.0", "react-hook-form": "^7.12.2", "react-icons": "^3.11.0", - "react-movable": "^3.0.2", "react-router-dom": "^5.2.0", "react-spinners": "^0.11.0", + "react-virtuoso": "^2.2.8", "validator": "^13.6.0", "yup": "^0.32.9" }, @@ -38,6 +39,7 @@ "@types/node": "^16.11.0", "@types/qs": "^6.9.7", "@types/react": "^17.0.0", + "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.0", "@types/react-helmet": "^6.1.2", "@types/react-router-dom": "^5.1.8", diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 551591f..71a7dbf 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -69,3 +69,18 @@ export function transformBytes(bytes: number, decimals = 2): string { return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; } + +/** + * + * @param array Array + * @param from From index + * @param to To index + * @returns Reordered array + */ +export function arrayMove(array: T[], from: number, to: number) { + const result = array.slice(); + const [removed] = result.splice(from, 1); + + result.splice(to, 0, removed); + return result; +} diff --git a/src/hooks/useDidMountEffect.ts b/src/hooks/useDidMountEffect.ts new file mode 100644 index 0000000..d2052c8 --- /dev/null +++ b/src/hooks/useDidMountEffect.ts @@ -0,0 +1,15 @@ +import React, { useEffect, useRef } from 'react'; + +const useDidMountEffect = (effect: React.EffectCallback, deps?: React.DependencyList | undefined) => { + const didMount = useRef(false); + + useEffect(() => { + if (didMount.current) { + effect(); + } else { + didMount.current = true; + } + }, deps); +}; + +export default useDidMountEffect; diff --git a/src/hooks/useFetchCourseQuery.ts b/src/hooks/useFetchCourseQuery.ts index aaa215b..8a3de78 100644 --- a/src/hooks/useFetchCourseQuery.ts +++ b/src/hooks/useFetchCourseQuery.ts @@ -7,7 +7,6 @@ import { useEffect } from 'react'; /** * Hooks */ -import { useErrorHandler } from 'react-error-boundary'; import useAsync from './useAsync'; import { useRootStore } from './useRootStore'; @@ -17,17 +16,10 @@ const useFetchCourseQuery = (id: string) => { const { courseStore: { courseService }, } = useRootStore(); - const handleError = useErrorHandler(); const fetch = async (id: string) => { - try { - const result = await courseService.fetchById(id); - return result.data; - } catch (error: any) { - if (!error.response) { - handleError(error); - } - } + const result = await courseService.fetchById(id); + return result.data; }; const { execute: fetchCourseById, ...state } = useAsync>(fetch); diff --git a/src/pages/courses/edit/components/LessonList/LessonList.tsx b/src/pages/courses/edit/components/LessonList/LessonList.tsx index 8ab888a..32f1911 100644 --- a/src/pages/courses/edit/components/LessonList/LessonList.tsx +++ b/src/pages/courses/edit/components/LessonList/LessonList.tsx @@ -1,7 +1,10 @@ import React, { useState } from 'react'; +import { ItemProps, Virtuoso } from 'react-virtuoso'; +import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from 'react-beautiful-dnd'; import moment from 'moment'; -import { List, arrayMove } from 'react-movable'; -import { Flex, Box, Stack, Text, IconButton, Icon, Button } from '@chakra-ui/react'; +import * as helpers from '@educt/helpers'; +import { Flex, Box, Text, IconButton, Icon, Button } from '@chakra-ui/react'; +import { DeleteIcon, DragHandleIcon } from '@chakra-ui/icons'; /** * Types @@ -12,11 +15,10 @@ import type { ICourse, ILesson } from '@educt/interfaces'; * Hooks */ import { useHistory } from 'react-router'; - import { useColorMode } from '@chakra-ui/react'; -import { DeleteIcon } from '@chakra-ui/icons'; import { MdTimer, MdAttachment } from 'react-icons/md'; -import { DragHandleIcon } from '@chakra-ui/icons'; +import { useRootStore } from '@educt/hooks/useRootStore'; +import useDidMountEffect from '@educt/hooks/useDidMountEffect'; type LessonListPropsType = { course: Omit; @@ -28,117 +30,189 @@ const CreateLessonButton: React.FC<{ id: string }> = ({ id }) => { const handleCreateLesson = (): void => history.push(`${id}/create-lesson`); return ( - - - + ); }; const LessonList: React.FC = ({ course }) => { const history = useHistory(); + const { lessonService } = useRootStore(); const [lessons, setLessons] = useState(course.lessons); - const { colorMode } = useColorMode(); + const handleCreateLesson = (): void => history.push('/lessons/create'); const handleEditLesson = (id: string): void => history.push(`/lessons/edit/${id}`); const handleDeleteLesson = (id: string): void => undefined; + const handleChangeOrder = async (ids: string[]) => { + try { + const data = await lessonService.saveOrder(ids); + return data; + } catch (error) { + console.error(error); + } + }; + const handleDragEnd = (result: DropResult) => { + if (!result.destination) { + return; + } - return ( - - {course.lessons.length !== 0 ? ( - - setLessons(arrayMove(lessons, oldIndex, newIndex))} - renderList={({ children, props }) => {children}} - renderItem={({ value: lesson, props, isDragged, index }) => ( - { + return ({ + provided, + lesson, + index, + }: { + provided: DraggableProvided; + lesson: ILesson; + isDragging: boolean; + index: number; + }) => { + const { colorMode } = useColorMode(); + return ( + + + + + + + - - + + - - - - - - - - - {index}. - - {lesson.title} - - - Sergey Yarkov - - + + + + {index + 1}. + + {lesson.title} + + + {course.teacher.fullname} + + + + + + + + } /> + + + + + + + + ~{moment.duration(lesson.duration, 'minutes').humanize()} + + + + + + + + 4 attachments + + - - - - - handleDeleteLesson(lesson.id)} - aria-label='Delete lesson' - variant='ghost' - colorScheme='red' - icon={} - /> - - - - - - - - ~{moment.duration(lesson.duration, 'minutes').humanize()} - - - - - - - - 4 attachments - - - - - - )} - /> - + + + ); + }; + }, []); + + const HeightPreservingItem: React.ComponentType = React.useMemo(() => { + return ({ children, ...props }) => { + return ( + // the height is necessary to prevent the item container from collapsing, which confuses Virtuoso measurements +
+ {children} +
+ ); + }; + }, []); + + useDidMountEffect(() => { + handleChangeOrder(lessons.map(lesson => lesson.id)); + }, [lessons]); + + return ( + + {lessons.length !== 0 ? ( + <> + + Total: ({lessons.length}) + + + + ( + + )} + > + {provided => { + return ( +
+ { + return ( + + {provided => ( + + )} + + ); + }} + /> +
+ ); + }} +
+
+ ) : ( - + No lessons have been added to this course yet - + + + )} diff --git a/src/pages/courses/edit/edit.tsx b/src/pages/courses/edit/edit.tsx index add76a2..93b89d0 100644 --- a/src/pages/courses/edit/edit.tsx +++ b/src/pages/courses/edit/edit.tsx @@ -34,7 +34,9 @@ const EditCoursePage: React.FC = () => { /** * Not Found */ - if (error?.response?.status === 404) return ; + if (error?.response?.status === 404) { + return ; + } /** * Loading diff --git a/src/services/LessonService.ts b/src/services/LessonService.ts new file mode 100644 index 0000000..9e42cc9 --- /dev/null +++ b/src/services/LessonService.ts @@ -0,0 +1,23 @@ +import { IApiRespose } from '@educt/interfaces'; +import { AxiosInstance } from 'axios'; + +export default class LessonService { + public api: AxiosInstance; + + constructor(api: AxiosInstance) { + this.api = api; + } + + /** + * Save order of lessons + * + * @param ids Ordered ids of lessons + * @returns Empty data + */ + public async saveOrder(ids: string[]): Promise> { + const result = await this.api.post('/v1/lessons/save-order', { + ids, + }); + return result.data; + } +} diff --git a/src/stores/RootStore.ts b/src/stores/RootStore.ts index 3cab05b..d23eb76 100644 --- a/src/stores/RootStore.ts +++ b/src/stores/RootStore.ts @@ -14,6 +14,7 @@ import UserStore from './UserStore'; import UIStore from './UIStore'; import CategoryStore from './CategoryStore'; import CourseStore from './CourseStore'; +import LessonService from '@educt/services/LessonService'; export default class RootStore { public courseStore: CourseStore; @@ -28,12 +29,16 @@ export default class RootStore { public apiService: ApiService; + public lessonService: LessonService; + constructor() { /** * Api service */ this.apiService = new ApiService(this, apiConfig); + this.lessonService = new LessonService(this.apiService.api); + /** * Stores */