feat: create course validation & creating course

This commit is contained in:
Sergey Yarkov 2021-10-27 23:39:33 +03:00
parent 5604f2e85f
commit 4d0fef3fb1
6 changed files with 198 additions and 38 deletions

View File

@ -44,7 +44,7 @@ const Layout: React.FC<LayoutPropsType> = ({ children, roles }) => {
as='main' as='main'
flex='1 0' flex='1 0'
maxW='85rem' maxW='85rem'
ml='25rem' ml='20rem'
mt='7rem' mt='7rem'
mb='1rem' mb='1rem'
pr='2rem' pr='2rem'

View File

@ -85,7 +85,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ courses, isLoading }) => {
{deletingCourse && <DeleteCourseDialog onClose={onCloseDeleteDialog} isOpen={isOpenDeleteDialog} />} {deletingCourse && <DeleteCourseDialog onClose={onCloseDeleteDialog} isOpen={isOpenDeleteDialog} />}
{courses.length !== 0 ? ( {courses.length !== 0 ? (
<Grid <Grid
templateColumns='repeat(3, 1fr)' templateColumns='repeat(3, minmax(0, 1fr))'
gap='6' gap='6'
sx={{ sx={{
'@media (max-width: 1280px)': { '@media (max-width: 1280px)': {

View File

@ -1,22 +1,46 @@
import React from 'react'; import React, { useState } from 'react';
import { Box, Grid, useToast } from '@chakra-ui/react'; import {
import { Input } from '@chakra-ui/react'; Box,
import { Flex, Button, Textarea } from '@chakra-ui/react'; Grid,
import { FormControl, FormLabel, InputGroup, InputRightElement } from '@chakra-ui/react'; useToast,
Text,
Flex,
Button,
Textarea,
FormControl,
FormLabel,
InputGroup,
InputRightElement,
Input,
} from '@chakra-ui/react';
import { MdCloudUpload } from 'react-icons/md';
import { SubmitHandler, useForm, useWatch, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import AsyncSelect from 'react-select/async'; import AsyncSelect from 'react-select/async';
import * as helpers from '@educt/helpers';
/** /**
* Types * Types
*/ */
import { MdCloudUpload } from 'react-icons/md'; import { OptionType } from '@educt/types';
import { CourseStatusEnum, UserRoleEnum } from '@educt/enums';
/**
* Hooks
*/
import { useHistory } from 'react-router';
import { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
import { UserRoleEnum } from '@educt/enums';
import { useErrorHandler } from 'react-error-boundary'; import { useErrorHandler } from 'react-error-boundary';
import { SubmitHandler, useForm, Controller } from 'react-hook-form'; import useIsMountedRef from '@educt/hooks/useIsMountedRef';
/**
* Schema
*/
import CreateCourseSchema from './CreateCourseForm.validator';
type CreateCourseInputType = { type CreateCourseInputType = {
title: string; title: string;
image: File; image: FileList;
category_id: string; category_id: string;
description: string; description: string;
teacher_id: string; teacher_id: string;
@ -27,21 +51,45 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
const { const {
userStore: { userService }, userStore: { userService },
categoryStore: { categoryService }, categoryStore: { categoryService },
courseStore: { courseService },
} = useRootStore(); } = useRootStore();
const { register, formState, handleSubmit, control, reset } = useForm<CreateCourseInputType>(); const {
register,
handleSubmit,
formState: { errors },
control,
watch,
reset,
} = useForm<CreateCourseInputType>({
resolver: yupResolver(CreateCourseSchema),
});
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const toast = useToast(); const toast = useToast();
const history = useHistory();
const isMountedRef = useIsMountedRef();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [status, setStatus] = useState<CourseStatusEnum | undefined>(undefined);
const title = useWatch({
control,
name: 'title',
defaultValue: '',
});
/** /**
* Load users into select field * Load users into select field
*/ */
const loadUsersOptions = async (inputValue: string) => { const loadUsersOptions = async (inputValue: string): Promise<OptionType[] | undefined> => {
try { try {
const users = await userService.fetchAll({ search: inputValue, role: UserRoleEnum.TEACHER }); const users = await userService.fetchAll({ search: inputValue, role: UserRoleEnum.TEACHER });
return users.data.map(user => ({
label: user.fullname, // TODO: remove filter later
value: user.id, return users.data
})); .filter(user => helpers.userContainRoles(user.roles, [UserRoleEnum.TEACHER]))
.map(user => ({
label: user.fullname,
value: user.id,
}));
} catch (error: any) { } catch (error: any) {
if (error.response) { if (error.response) {
toast({ title: 'Error' }); toast({ title: 'Error' });
@ -54,7 +102,7 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
/** /**
* Load categories into select filed * Load categories into select filed
*/ */
const loadCategoriesOptions = async (inputValue: string) => { const loadCategoriesOptions = async (inputValue: string): Promise<OptionType[] | undefined> => {
try { try {
const categories = await categoryService.fetchAll(); const categories = await categoryService.fetchAll();
return categories.data.map(category => ({ return categories.data.map(category => ({
@ -74,27 +122,68 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
* Submit form handler * Submit form handler
*/ */
const onSubmit: SubmitHandler<CreateCourseInputType> = async data => { const onSubmit: SubmitHandler<CreateCourseInputType> = async data => {
console.log(data); try {
setIsLoading(true);
await courseService.create({
title: data.title,
description: data.description,
teacher_id: data.teacher_id,
category_id: data.category_id,
image: data.image[0],
status: status ?? CourseStatusEnum.DRAFT,
});
toast({ title: `Course successfully created.`, status: 'success' });
history.push('/courses');
} catch (error: any) {
if (error.response) {
if (error.response.status === 422) {
toast({ title: `${error.response.data.errors[0].message}`, status: 'error' });
} else {
toast({ title: `${error.message}`, status: 'error' });
}
} else {
handleError(error);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
setStatus(undefined);
}
}
}; };
return ( return (
<Box> <Box>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Grid templateColumns={{ md: '2.5fr 1fr' }} gap='5'> <Grid templateColumns={{ md: '2.5fr 1fr' }} gap='5'>
<FormControl id='title'> <FormControl id='title' isInvalid={!!errors.title}>
<FormLabel>Title</FormLabel> <FormLabel>Title</FormLabel>
<InputGroup> <InputGroup>
<Input size='md' placeholder='Type course title' type='text' {...register('title')} /> <Input pr='60px' size='md' placeholder='Type course title' type='text' {...register('title')} />
<InputRightElement fontSize='sm' mr='2' color='gray.500' children={'0/60'} /> <InputRightElement
mr='2'
children={
<Text as='small' color={!!errors.title || title.length > 60 ? 'red.500' : 'gray.500'}>
{title.length}/60
</Text>
}
/>
</InputGroup> </InputGroup>
<Text as='small' color='red.500'>
{errors.title?.message}
</Text>
</FormControl> </FormControl>
<FormControl id='image'>
<FormLabel>Image</FormLabel>
<Input size='md' variant='unstyled' type='file' {...register('image')} />
</FormControl>
<FormControl id='category_id' gridColumn='1'>
<FormLabel>Category</FormLabel>
<FormControl id='image' isInvalid={!!errors.image}>
<FormLabel>Image</FormLabel>
<Input size='md' variant='unstyled' {...register('image')} type='file' accept='image/*' />
<Text as='small' color='red.500'>
{errors.image?.message}
</Text>
</FormControl>
<FormControl id='category_id' gridColumn='1' isInvalid={!!errors.category_id}>
<FormLabel>Category</FormLabel>
<Controller <Controller
control={control} control={control}
name='category_id' name='category_id'
@ -111,8 +200,11 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
/> />
)} )}
/> />
<Text as='small' color='red.500'>
{errors.category_id?.message}
</Text>
</FormControl> </FormControl>
<FormControl id='teacher_id' gridColumn={{ md: '2' }} gridRow={{ md: '1' }}> <FormControl id='teacher_id' gridColumn={{ md: '2' }} gridRow={{ md: '1' }} isInvalid={!!errors.teacher_id}>
<FormLabel>Teacher</FormLabel> <FormLabel>Teacher</FormLabel>
<Controller <Controller
control={control} control={control}
@ -129,17 +221,41 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
/> />
)} )}
/> />
<Text as='small' color='red.500'>
{errors.teacher_id?.message}
</Text>
</FormControl> </FormControl>
<FormControl id='description' gridColumn='1'> <FormControl id='description' gridColumn='1' isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<Textarea resize='none' minH='150px' placeholder='Write some description for course...' /> <Textarea
resize='none'
minH='150px'
placeholder='Write some description for course...'
{...register('description')}
/>
<Text as='small' color='red.500'>
{errors.description?.message}
</Text>
</FormControl> </FormControl>
</Grid> </Grid>
<Flex mt='6'> <Flex mt='6'>
<Button type='submit' mr='2' colorScheme='blue' variant='outline' rightIcon={<MdCloudUpload size='14px' />}> <Button
loadingText='Saving...'
type='submit'
mr='2'
colorScheme='blue'
variant='outline'
rightIcon={<MdCloudUpload size='14px' />}
onClick={() => setStatus(CourseStatusEnum.DRAFT)}
>
Save as Draft Save as Draft
</Button> </Button>
<Button type='submit' colorScheme='green'> <Button
loadingText='Saving...'
type='submit'
colorScheme='green'
onClick={() => setStatus(CourseStatusEnum.PUBLISHED)}
>
Publish Publish
</Button> </Button>
</Flex> </Flex>

View File

@ -1,15 +1,21 @@
import yup from '@educt/schema'; import yup from '@educt/schema';
const MAX_FILE_SIZE = 300 * 1024; const FILE_SIZE = 160 * 1024;
const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/gif', 'image/png']; const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/gif', 'image/png'];
const CreateCourseSchema = yup.object().shape({ const CreateCourseSchema = yup.object().shape({
title: yup.string().required('Title field name is required'), title: yup.string().required('Title field name is required').max(60, 'Title must be at most 60 characters'),
description: yup.string().required('Description field name is required'), description: yup.string().required('Description field name is required'),
image: yup image: yup
.mixed() .mixed()
.test('fileSize', 'File size is too large', (value: File) => value && value.size >= MAX_FILE_SIZE) .test('fileSize', 'File size is too large', (value: FileList) => {
.test('fileFormat', 'Usupported file format', (value: File) => value && SUPPORTED_FORMATS.includes(value.type)) if (!value.length) return true;
return value[0] && value[0].size <= FILE_SIZE;
})
.test('fileFormat', 'This file format is not supported', (value: FileList) => {
if (!value.length) return true;
return value[0] && SUPPORTED_FORMATS.includes(value[0].type);
})
.optional(), .optional(),
category_id: yup.string().required('Category field is required'), category_id: yup.string().required('Category field is required'),
teacher_id: yup.string().required('Teacher field is required'), teacher_id: yup.string().required('Teacher field is required'),

View File

@ -1,6 +1,6 @@
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { IApiRespose, ICourse } from '@educt/interfaces'; import { IApiRespose, ICourse } from '@educt/interfaces';
import { FetchCoursesParams } from '@educt/types'; import { CreateCourseParamsType, FetchCoursesParams } from '@educt/types';
import { CourseStatusEnum } from '@educt/enums'; import { CourseStatusEnum } from '@educt/enums';
export class CourseService { export class CourseService {
@ -41,6 +41,30 @@ export class CourseService {
return result.data; return result.data;
} }
/**
*
* @param data Data for creating course
* @returns
*/
public async create(
data: CreateCourseParamsType
): Promise<IApiRespose<Pick<ICourse, 'id' | 'title' | 'description' | 'status' | 'created_at' | 'updated_at'>>> {
const formData = new FormData();
/**
* Append value to form-data format
*/
Object.entries(data).forEach(([key, value]) => value && formData.append(key, value));
const result = await this.api.post('/v1/courses', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return result.data;
}
/** /**
* Delete course by id * Delete course by id
* *

14
src/types/index.d.ts vendored
View File

@ -28,6 +28,11 @@ export type ConfirmEmailContainerDataType = {
expired_seconds: number; expired_seconds: number;
}; };
export type OptionType = {
value: string;
label: string;
};
export type ProfilePageStatusType = 'update-password' | 'update-email' | 'confirm-email' | 'default'; export type ProfilePageStatusType = 'update-password' | 'update-email' | 'confirm-email' | 'default';
export type ProfilePageDataType = { export type ProfilePageDataType = {
/** /**
@ -127,3 +132,12 @@ export type CreateUserParamsType = {
password: string; password: string;
}; };
export type UpdateUserParamsType = { first_name?: string; last_name?: string; email?: string }; export type UpdateUserParamsType = { first_name?: string; last_name?: string; email?: string };
export type CreateCourseParamsType = {
title: string;
description: string;
teacher_id: string;
category_id: string;
image: File | undefined;
status: CourseStatusEnum;
};