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",
"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": {
"version": "0.10.0",
"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-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",
"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;
title: string;
description: string;
color?: ColorType | undefined | null;
}
/**

View File

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

View File

@ -13,6 +13,7 @@ import { DeleteIcon, EditIcon } from '@chakra-ui/icons';
*/
import { ICourse } from '@educt/interfaces';
import { CourseStatusEnum } from '@educt/enums';
import { SetCourseStatusFnType } from '@educt/hooks/useSetCourseStatusQuery';
/**
* Components
@ -29,7 +30,7 @@ import { useRootStore } from '@educt/hooks/useRootStore';
export type CourseItemPropsType = {
course: Omit<ICourse, 'teacher' | 'students' | 'lessons'>;
onDelete: (course: Pick<ICourse, 'id' | 'title'>) => void;
onSetStatus: (id: string, status: CourseStatusEnum) => Promise<void>;
onSetStatus: SetCourseStatusFnType;
};
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 { useRootStore } from '@educt/hooks/useRootStore';
import { useDisclosure } from '@chakra-ui/hooks';
import { useToast } from '@chakra-ui/toast';
import { useErrorHandler } from 'react-error-boundary';
/**
* Contexts
*/
import { CoursesPageContext } from '@educt/contexts';
import useSetCourseStatusQuery from '@educt/hooks/useSetCourseStatusQuery';
type CourseListPropsType = {
render: React.FC<CourseItemPropsType>;
@ -39,8 +38,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
} = useRootStore();
const { selectedCategory, courseStatus, deletingCourse, setDeletingCourse } = useContext(CoursesPageContext);
const { onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog, isOpen: isOpenDeleteDialog } = useDisclosure();
const handleError = useErrorHandler();
const toast = useToast();
const { setCourseStatus } = useSetCourseStatusQuery();
const { courses, isLoading } = courseStore;
/**
@ -55,22 +53,6 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
}
}, [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 />;
return (
@ -88,7 +70,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
setDeletingCourse(course);
onOpenDeleteDialog();
}}
onSetStatus={onSetStatus}
onSetStatus={setCourseStatus}
/>
))}
</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 { Box, Stack, Text, Button, StackDivider } from '@chakra-ui/react';
import React, { useState } from 'react';
import moment from 'moment';
import { List, arrayMove } from 'react-movable';
import { Flex, Box, Stack, Text, IconButton, Icon, Button } from '@chakra-ui/react';
/**
* Types
*/
import type { LessonItemPropsType } from './LessonItem';
import type { ICourse } from '@educt/interfaces';
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';
type LessonListPropsType = {
render: React.FC<LessonItemPropsType>;
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 (
<Box>
{course.lessons.length !== 0 ? (
<Box>
<Stack mt='4'>
{course.lessons.map(lesson => (
<Item key={lesson.id} lesson={lesson} />
))}
</Stack>
<List
transitionDuration={100}
lockVertically
values={lessons}
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} />
</Box>
) : (

View File

@ -1,4 +1,3 @@
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 { observer } from 'mobx-react';
import { Redirect } from 'react-router-dom';
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 { 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';
/**
@ -44,12 +46,14 @@ const EditCoursePage: React.FC<IPageProps> = () => {
<Helmet>
<title>Editing {course.title}</title>
</Helmet>
<Flex>
<Box>
<Flex justifyContent='space-between' alignItems='center' flexWrap='wrap'>
<Box mr='3'>
<Heading as='h1'>Course editor</Heading>
<Text mt='2'>{course.title}</Text>
</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>
<Box>
<Tabs isLazy mt='8'>
@ -74,7 +78,7 @@ const EditCoursePage: React.FC<IPageProps> = () => {
</Box>
</TabPanel>
<TabPanel>
<LessonList render={LessonItem} course={course} />
<LessonList course={course} />
</TabPanel>
<TabPanel>
<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>
<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>
</Tag>
</Box>