feat: some refactor & added draggable lesson

This commit is contained in:
Sergey Yarkov 2021-11-21 21:14:53 +03:00
parent aa5bf76cc7
commit cc5581bbc4
14 changed files with 243 additions and 136 deletions

5
package-lock.json generated
View File

@ -2101,6 +2101,11 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "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-refresh": { "react-refresh": {
"version": "0.10.0", "version": "0.10.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz",

View File

@ -27,6 +27,7 @@
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-hook-form": "^7.12.2", "react-hook-form": "^7.12.2",
"react-icons": "^3.11.0", "react-icons": "^3.11.0",
"react-movable": "^3.0.2",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-spinners": "^0.11.0", "react-spinners": "^0.11.0",
"validator": "^13.6.0", "validator": "^13.6.0",

View File

@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai';
import { CourseStatusEnum } from '@educt/enums';
import useSetCourseStatusQuery from '@educt/hooks/useSetCourseStatusQuery';
type SetCourseStatusButtonPropsType = {
courseId: string;
currentStatus: CourseStatusEnum;
loadingText?: string | undefined;
};
const SetCourseStatusButton: React.FC<SetCourseStatusButtonPropsType> = ({
courseId,
currentStatus,
loadingText = 'Wait a second...',
}) => {
const [status, setStatus] = useState<keyof typeof CourseStatusEnum>(currentStatus);
const { setCourseStatus, isLoading } = useSetCourseStatusQuery();
const handleChangeStatus = async (newStatus: CourseStatusEnum) => {
await setCourseStatus(courseId, newStatus);
setStatus(newStatus);
};
const Components: { [k in keyof typeof CourseStatusEnum]: any } = {
PUBLISHED: (
<Button
onClick={() => handleChangeStatus(CourseStatusEnum.DRAFT)}
isLoading={isLoading}
loadingText={loadingText}
leftIcon={<AiOutlineEyeInvisible />}
>
Mark as Draft
</Button>
),
DRAFT: (
<Button
onClick={() => handleChangeStatus(CourseStatusEnum.PUBLISHED)}
isLoading={isLoading}
loadingText={loadingText}
leftIcon={<AiOutlineEye />}
>
Publish course
</Button>
),
};
return Components[status];
};
export default SetCourseStatusButton;

View File

@ -0,0 +1,3 @@
import SetCourseStatusButton from './SetCourseStatusButton';
export default SetCourseStatusButton;

View File

@ -0,0 +1,49 @@
/**
* Hooks
*/
import { CourseStatusEnum } from '@educt/enums';
import { useErrorHandler } from 'react-error-boundary';
import useAsync from './useAsync';
import { useRootStore } from './useRootStore';
import { useToast } from '@chakra-ui/toast';
type QueryResponseDataType = {} | undefined;
export type SetCourseStatusFnType = (
id: string,
newStatus: CourseStatusEnum
) => Promise<QueryResponseDataType | undefined>;
const useSetCourseStatusQuery = () => {
const { courseStore } = useRootStore();
const toast = useToast();
const handleError = useErrorHandler();
const setCourseStatus = async (id: string, newStatus: CourseStatusEnum) => {
try {
const result = await courseStore.setCourseStatus(id, newStatus);
switch (newStatus) {
case CourseStatusEnum.PUBLISHED:
toast({ title: 'Course has been published.', status: 'success' });
break;
case CourseStatusEnum.DRAFT:
toast({ title: 'Course has been marked as draft.', status: 'info' });
break;
default:
break;
}
return result.data;
} catch (error: any) {
if (error.response) {
toast({ title: error.message, status: 'error' });
} else {
handleError(error);
}
}
};
const { execute, ...state } = useAsync<QueryResponseDataType, Parameters<SetCourseStatusFnType>>(setCourseStatus);
return { setCourseStatus: execute, ...state };
};
export default useSetCourseStatusQuery;

View File

@ -50,6 +50,7 @@ export interface ICategory {
id: string; id: string;
title: string; title: string;
description: string; description: string;
color?: ColorType | undefined | null;
} }
/** /**

View File

@ -22,6 +22,7 @@ export type CategoryItemPropsType = {
const CategoryItem: React.FC<CategoryItemPropsType> = ({ category }) => { const CategoryItem: React.FC<CategoryItemPropsType> = ({ category }) => {
const { selectedCategory, setSelectedCategory } = useContext(CoursesPageContext); const { selectedCategory, setSelectedCategory } = useContext(CoursesPageContext);
const isSelected = selectedCategory?.id === category.id;
/** /**
* Handle click on change category * Handle click on change category
@ -31,10 +32,12 @@ const CategoryItem: React.FC<CategoryItemPropsType> = ({ category }) => {
return ( return (
<Tag <Tag
borderRadius='full' borderRadius='full'
variant={selectedCategory?.id === category.id ? 'solid' : 'outline'} variant={isSelected ? 'solid' : 'outline'}
colorScheme='blue' boxShadow={`inset 0 0 0px 1px ${category.color?.hex}`}
color={isSelected ? 'white' : category.color?.hex}
cursor='pointer' cursor='pointer'
transition='all .1s' transition='all .1s'
bg={isSelected ? `${category.color?.hex}` : 'none'}
_hover={{ opacity: '.8' }} _hover={{ opacity: '.8' }}
onClick={onCategoryChange} onClick={onCategoryChange}
> >

View File

@ -13,6 +13,7 @@ import { DeleteIcon, EditIcon } from '@chakra-ui/icons';
*/ */
import { ICourse } from '@educt/interfaces'; import { ICourse } from '@educt/interfaces';
import { CourseStatusEnum } from '@educt/enums'; import { CourseStatusEnum } from '@educt/enums';
import { SetCourseStatusFnType } from '@educt/hooks/useSetCourseStatusQuery';
/** /**
* Components * Components
@ -29,7 +30,7 @@ import { useRootStore } from '@educt/hooks/useRootStore';
export type CourseItemPropsType = { export type CourseItemPropsType = {
course: Omit<ICourse, 'teacher' | 'students' | 'lessons'>; course: Omit<ICourse, 'teacher' | 'students' | 'lessons'>;
onDelete: (course: Pick<ICourse, 'id' | 'title'>) => void; onDelete: (course: Pick<ICourse, 'id' | 'title'>) => void;
onSetStatus: (id: string, status: CourseStatusEnum) => Promise<void>; onSetStatus: SetCourseStatusFnType;
}; };
const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStatus }) => { const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStatus }) => {

View File

@ -20,13 +20,12 @@ import LoadingList from '@educt/components/LoadingList';
import { useContext, useEffect } from 'react'; import { useContext, useEffect } from 'react';
import { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
import { useDisclosure } from '@chakra-ui/hooks'; import { useDisclosure } from '@chakra-ui/hooks';
import { useToast } from '@chakra-ui/toast';
import { useErrorHandler } from 'react-error-boundary';
/** /**
* Contexts * Contexts
*/ */
import { CoursesPageContext } from '@educt/contexts'; import { CoursesPageContext } from '@educt/contexts';
import useSetCourseStatusQuery from '@educt/hooks/useSetCourseStatusQuery';
type CourseListPropsType = { type CourseListPropsType = {
render: React.FC<CourseItemPropsType>; render: React.FC<CourseItemPropsType>;
@ -39,8 +38,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
} = useRootStore(); } = useRootStore();
const { selectedCategory, courseStatus, deletingCourse, setDeletingCourse } = useContext(CoursesPageContext); const { selectedCategory, courseStatus, deletingCourse, setDeletingCourse } = useContext(CoursesPageContext);
const { onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog, isOpen: isOpenDeleteDialog } = useDisclosure(); const { onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog, isOpen: isOpenDeleteDialog } = useDisclosure();
const handleError = useErrorHandler(); const { setCourseStatus } = useSetCourseStatusQuery();
const toast = useToast();
const { courses, isLoading } = courseStore; const { courses, isLoading } = courseStore;
/** /**
@ -55,22 +53,6 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
} }
}, [courseStore, selectedCategory, courseStatus]); }, [courseStore, selectedCategory, courseStatus]);
/**
* Handle update course status
*/
const onSetStatus = async (id: string, status: CourseStatusEnum) => {
try {
await courseStore.setCourseStatus(id, status);
toast({ title: 'Status updated.', status: 'info' });
} catch (error: any) {
if (error.response) {
toast({ title: error.message, status: 'error' });
} else {
handleError(error);
}
}
};
if (courses === null || isLoading) return <LoadingList />; if (courses === null || isLoading) return <LoadingList />;
return ( return (
@ -88,7 +70,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
setDeletingCourse(course); setDeletingCourse(course);
onOpenDeleteDialog(); onOpenDeleteDialog();
}} }}
onSetStatus={onSetStatus} onSetStatus={setCourseStatus}
/> />
))} ))}
</Grid> </Grid>

View File

@ -1,91 +0,0 @@
import React from 'react';
import moment from 'moment';
import { Flex, Box, Text, Icon, Button, IconButton } from '@chakra-ui/react';
import { DeleteIcon } from '@chakra-ui/icons';
import { MdTimer, MdAttachment } from 'react-icons/md';
/**
* Types
*/
import type { ILesson } from '@educt/interfaces';
/**
* Hooks
*/
import { useHistory } from 'react-router';
import { useColorModeValue } from '@chakra-ui/color-mode';
export type LessonItemPropsType = {
lesson: ILesson;
};
const LessonItem: React.FC<LessonItemPropsType> = ({ lesson }) => {
const history = useHistory();
const handleEditLesson = (): void => history.push(`/lessons/edit/${lesson.id}`);
const handleDeleteLesson = (): void => undefined;
return (
<Box key={lesson.title} p='3' borderBottomWidth='1px'>
<Flex flexDirection={{ base: 'column', md: 'row' }} justifyContent='space-between'>
<Flex
flexDirection={{ base: 'column', md: 'row' }}
textAlign={{ base: 'center', md: 'left' }}
alignItems='center'
mr={{ md: '4' }}
>
<Box mr='3'>
<Box bg={lesson.color?.hex || 'gray.100'} minH='50px' minW='50px' borderRadius='md' />
</Box>
<Box>
<Text fontSize='lg' fontWeight='medium' mt={{ base: '3', md: '0' }}>
<Text as='span' color='gray.500' mr='1' fontSize='md' fontWeight='normal'>
1.
</Text>
{lesson.title}
</Text>
<Text mt='2' color='gray.500'>
Sergey Yarkov
</Text>
</Box>
</Flex>
<Box>
<Flex flexDirection='column' alignItems={{ base: 'center' }} mt={{ base: '4' }}>
<Flex flexDirection={{ base: 'column', md: 'row' }}>
<Button onClick={handleEditLesson} size='md' p='0 60px' mr='1'>
Edit
</Button>
<IconButton
onClick={handleDeleteLesson}
aria-label='Delete lesson'
variant='ghost'
colorScheme='red'
icon={<DeleteIcon />}
/>
</Flex>
<Flex justifyContent='flex-end' mt={{ base: '2' }}>
<Text as='span' color='gray.500' mr='3'>
<Text as='span' verticalAlign='middle' mr='1'>
<Icon as={MdTimer} />
</Text>
<Text as='small' verticalAlign='middle'>
~{moment.duration(lesson.duration, 'minutes').humanize()}
</Text>
</Text>
<Text as='span' color='gray.500'>
<Text as='span' verticalAlign='middle' mr='1'>
<Icon as={MdAttachment} />
</Text>
<Text as='small' verticalAlign='middle'>
4 attachments
</Text>
</Text>
</Flex>
</Flex>
</Box>
</Flex>
</Box>
);
};
export default LessonItem;

View File

@ -1,19 +1,24 @@
import React from 'react'; import React, { useState } from 'react';
import { Box, Stack, Text, Button, StackDivider } from '@chakra-ui/react'; import moment from 'moment';
import { List, arrayMove } from 'react-movable';
import { Flex, Box, Stack, Text, IconButton, Icon, Button } from '@chakra-ui/react';
/** /**
* Types * Types
*/ */
import type { LessonItemPropsType } from './LessonItem'; import type { ICourse, ILesson } from '@educt/interfaces';
import type { ICourse } from '@educt/interfaces';
/** /**
* Hooks * Hooks
*/ */
import { useHistory } from 'react-router'; 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';
type LessonListPropsType = { type LessonListPropsType = {
render: React.FC<LessonItemPropsType>;
course: Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>; course: Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>;
}; };
@ -31,16 +36,103 @@ const CreateLessonButton: React.FC<{ id: string }> = ({ id }) => {
); );
}; };
const LessonList: React.FC<LessonListPropsType> = ({ render: Item, course }) => { const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
const history = useHistory();
const [lessons, setLessons] = useState<ILesson[]>(course.lessons);
const { colorMode } = useColorMode();
const handleEditLesson = (id: string): void => history.push(`/lessons/edit/${id}`);
const handleDeleteLesson = (id: string): void => undefined;
return ( return (
<Box> <Box>
{course.lessons.length !== 0 ? ( {course.lessons.length !== 0 ? (
<Box> <Box>
<Stack mt='4'> <List
{course.lessons.map(lesson => ( transitionDuration={100}
<Item key={lesson.id} lesson={lesson} /> lockVertically
))} values={lessons}
</Stack> onChange={({ oldIndex, newIndex }) => setLessons(arrayMove(lessons, oldIndex, newIndex))}
renderList={({ children, props }) => <Stack {...props}>{children}</Stack>}
renderItem={({ value: lesson, props, isDragged, index }) => (
<Box
{...props}
zIndex='1'
bg={colorMode === 'dark' ? 'gray.700' : 'gray.50'}
borderRadius='lg'
position='relative'
p='3'
borderBottomWidth='1px'
>
<Box
data-movable-handle
cursor={isDragged ? 'grabbing' : 'grab'}
position='absolute'
bottom='50%'
left='-20px'
>
<DragHandleIcon />
</Box>
<Flex flexDirection={{ base: 'column', md: 'row' }} justifyContent='space-between'>
<Flex
flexDirection={{ base: 'column', md: 'row' }}
textAlign={{ base: 'center', md: 'left' }}
alignItems='center'
mr={{ md: '4' }}
>
<Box mr='3'>
<Box bg={lesson.color?.hex || 'gray.100'} minH='50px' minW='50px' borderRadius='md' />
</Box>
<Box>
<Text fontSize='lg' fontWeight='medium' mt={{ base: '3', md: '0' }}>
<Text as='span' color='gray.500' mr='1' fontSize='md' fontWeight='normal'>
{index}.
</Text>
{lesson.title}
</Text>
<Text mt='2' color='gray.500'>
Sergey Yarkov
</Text>
</Box>
</Flex>
<Box>
<Flex flexDirection='column' alignItems={{ base: 'center' }} mt={{ base: '4' }}>
<Flex flexDirection={{ base: 'column', md: 'row' }}>
<Button onClick={() => handleEditLesson(lesson.id)} size='md' p='0 60px' mr='1'>
Edit
</Button>
<IconButton
onClick={() => handleDeleteLesson(lesson.id)}
aria-label='Delete lesson'
variant='ghost'
colorScheme='red'
icon={<DeleteIcon />}
/>
</Flex>
<Flex justifyContent='flex-end' mt={{ base: '2' }}>
<Text as='span' color='gray.500' mr='3'>
<Text as='span' verticalAlign='middle' mr='1'>
<Icon as={MdTimer} />
</Text>
<Text as='small' verticalAlign='middle'>
~{moment.duration(lesson.duration, 'minutes').humanize()}
</Text>
</Text>
<Text as='span' color='gray.500'>
<Text as='span' verticalAlign='middle' mr='1'>
<Icon as={MdAttachment} />
</Text>
<Text as='small' verticalAlign='middle'>
4 attachments
</Text>
</Text>
</Flex>
</Flex>
</Box>
</Flex>
</Box>
)}
/>
<CreateLessonButton id={course.id} /> <CreateLessonButton id={course.id} />
</Box> </Box>
) : ( ) : (

View File

@ -1,4 +1,3 @@
import LessonList from './LessonList'; import LessonList from './LessonList';
import LessonItem from './LessonItem';
export { LessonList, LessonItem }; export default LessonList;

View File

@ -1,5 +1,6 @@
import React, { useEffect } from 'react'; import React from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { observer } from 'mobx-react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { Box, Heading, Text, Tab, TabList, Flex, Tabs, TabPanels, TabPanel } from '@chakra-ui/react'; import { Box, Heading, Text, Tab, TabList, Flex, Tabs, TabPanels, TabPanel } from '@chakra-ui/react';
@ -13,7 +14,8 @@ import type { IPageProps } from '@educt/interfaces';
*/ */
import LoadingPage from '@educt/components/LoadingPage'; import LoadingPage from '@educt/components/LoadingPage';
import { EditCourseForm } from '@educt/components/Forms/CourseForm'; import { EditCourseForm } from '@educt/components/Forms/CourseForm';
import { LessonList, LessonItem } from './components/LessonList'; import SetCourseStatusButton from '@educt/components/SetCourseStatusButton';
import LessonList from './components/LessonList';
import { StudentList, StudentItem } from './components/StudentList'; import { StudentList, StudentItem } from './components/StudentList';
/** /**
@ -44,12 +46,14 @@ const EditCoursePage: React.FC<IPageProps> = () => {
<Helmet> <Helmet>
<title>Editing {course.title}</title> <title>Editing {course.title}</title>
</Helmet> </Helmet>
<Flex> <Flex justifyContent='space-between' alignItems='center' flexWrap='wrap'>
<Box> <Box mr='3'>
<Heading as='h1'>Course editor</Heading> <Heading as='h1'>Course editor</Heading>
<Text mt='2'>{course.title}</Text> <Text mt='2'>{course.title}</Text>
</Box> </Box>
<Box></Box> <Box ml='auto' alignSelf='flex-end' justifySelf='flex-end' mt={{ base: '7', md: '0' }}>
<SetCourseStatusButton courseId={course.id} currentStatus={course.status} />
</Box>
</Flex> </Flex>
<Box> <Box>
<Tabs isLazy mt='8'> <Tabs isLazy mt='8'>
@ -74,7 +78,7 @@ const EditCoursePage: React.FC<IPageProps> = () => {
</Box> </Box>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<LessonList render={LessonItem} course={course} /> <LessonList course={course} />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<StudentList render={StudentItem} students={course.students} /> <StudentList render={StudentItem} students={course.students} />
@ -86,4 +90,4 @@ const EditCoursePage: React.FC<IPageProps> = () => {
); );
}; };
export default EditCoursePage; export default observer(EditCoursePage);

View File

@ -30,7 +30,13 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course }) => {
</Box> </Box>
<Box> <Box>
<Tag variant='subtle' colorScheme='green' borderRadius='full' alignSelf='flex-start'> <Tag
color='white'
boxShadow={`inset 0 0 0px 1px ${course.category.color?.hex}`}
bg={course.category.color?.hex}
borderRadius='full'
alignSelf='flex-start'
>
<TagLabel>{course.category.title}</TagLabel> <TagLabel>{course.category.title}</TagLabel>
</Tag> </Tag>
</Box> </Box>