mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
feat: create course validation & creating course
This commit is contained in:
parent
5604f2e85f
commit
4d0fef3fb1
@ -44,7 +44,7 @@ const Layout: React.FC<LayoutPropsType> = ({ children, roles }) => {
|
||||
as='main'
|
||||
flex='1 0'
|
||||
maxW='85rem'
|
||||
ml='25rem'
|
||||
ml='20rem'
|
||||
mt='7rem'
|
||||
mb='1rem'
|
||||
pr='2rem'
|
||||
|
||||
@ -85,7 +85,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ courses, isLoading }) => {
|
||||
{deletingCourse && <DeleteCourseDialog onClose={onCloseDeleteDialog} isOpen={isOpenDeleteDialog} />}
|
||||
{courses.length !== 0 ? (
|
||||
<Grid
|
||||
templateColumns='repeat(3, 1fr)'
|
||||
templateColumns='repeat(3, minmax(0, 1fr))'
|
||||
gap='6'
|
||||
sx={{
|
||||
'@media (max-width: 1280px)': {
|
||||
|
||||
@ -1,22 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box, Grid, useToast } from '@chakra-ui/react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { Flex, Button, Textarea } from '@chakra-ui/react';
|
||||
import { FormControl, FormLabel, InputGroup, InputRightElement } from '@chakra-ui/react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
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 * as helpers from '@educt/helpers';
|
||||
|
||||
/**
|
||||
* 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 { UserRoleEnum } from '@educt/enums';
|
||||
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 = {
|
||||
title: string;
|
||||
image: File;
|
||||
image: FileList;
|
||||
category_id: string;
|
||||
description: string;
|
||||
teacher_id: string;
|
||||
@ -27,18 +51,42 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
|
||||
const {
|
||||
userStore: { userService },
|
||||
categoryStore: { categoryService },
|
||||
courseStore: { courseService },
|
||||
} = 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 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
|
||||
*/
|
||||
const loadUsersOptions = async (inputValue: string) => {
|
||||
const loadUsersOptions = async (inputValue: string): Promise<OptionType[] | undefined> => {
|
||||
try {
|
||||
const users = await userService.fetchAll({ search: inputValue, role: UserRoleEnum.TEACHER });
|
||||
return users.data.map(user => ({
|
||||
|
||||
// TODO: remove filter later
|
||||
return users.data
|
||||
.filter(user => helpers.userContainRoles(user.roles, [UserRoleEnum.TEACHER]))
|
||||
.map(user => ({
|
||||
label: user.fullname,
|
||||
value: user.id,
|
||||
}));
|
||||
@ -54,7 +102,7 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
|
||||
/**
|
||||
* Load categories into select filed
|
||||
*/
|
||||
const loadCategoriesOptions = async (inputValue: string) => {
|
||||
const loadCategoriesOptions = async (inputValue: string): Promise<OptionType[] | undefined> => {
|
||||
try {
|
||||
const categories = await categoryService.fetchAll();
|
||||
return categories.data.map(category => ({
|
||||
@ -74,27 +122,68 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
|
||||
* Submit form handler
|
||||
*/
|
||||
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 (
|
||||
<Box>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid templateColumns={{ md: '2.5fr 1fr' }} gap='5'>
|
||||
<FormControl id='title'>
|
||||
<FormControl id='title' isInvalid={!!errors.title}>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<InputGroup>
|
||||
<Input size='md' placeholder='Type course title' type='text' {...register('title')} />
|
||||
<InputRightElement fontSize='sm' mr='2' color='gray.500' children={'0/60'} />
|
||||
<Input pr='60px' size='md' placeholder='Type course title' type='text' {...register('title')} />
|
||||
<InputRightElement
|
||||
mr='2'
|
||||
children={
|
||||
<Text as='small' color={!!errors.title || title.length > 60 ? 'red.500' : 'gray.500'}>
|
||||
{title.length}/60
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.title?.message}
|
||||
</Text>
|
||||
</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
|
||||
control={control}
|
||||
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 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>
|
||||
<Controller
|
||||
control={control}
|
||||
@ -129,17 +221,41 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.teacher_id?.message}
|
||||
</Text>
|
||||
</FormControl>
|
||||
<FormControl id='description' gridColumn='1'>
|
||||
<FormControl id='description' gridColumn='1' isInvalid={!!errors.description}>
|
||||
<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>
|
||||
</Grid>
|
||||
<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
|
||||
</Button>
|
||||
<Button type='submit' colorScheme='green'>
|
||||
<Button
|
||||
loadingText='Saving...'
|
||||
type='submit'
|
||||
colorScheme='green'
|
||||
onClick={() => setStatus(CourseStatusEnum.PUBLISHED)}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@ -1,15 +1,21 @@
|
||||
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 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'),
|
||||
image: yup
|
||||
.mixed()
|
||||
.test('fileSize', 'File size is too large', (value: File) => value && value.size >= MAX_FILE_SIZE)
|
||||
.test('fileFormat', 'Usupported file format', (value: File) => value && SUPPORTED_FORMATS.includes(value.type))
|
||||
.test('fileSize', 'File size is too large', (value: FileList) => {
|
||||
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(),
|
||||
category_id: yup.string().required('Category field is required'),
|
||||
teacher_id: yup.string().required('Teacher field is required'),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { IApiRespose, ICourse } from '@educt/interfaces';
|
||||
import { FetchCoursesParams } from '@educt/types';
|
||||
import { CreateCourseParamsType, FetchCoursesParams } from '@educt/types';
|
||||
import { CourseStatusEnum } from '@educt/enums';
|
||||
|
||||
export class CourseService {
|
||||
@ -41,6 +41,30 @@ export class CourseService {
|
||||
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
|
||||
*
|
||||
|
||||
14
src/types/index.d.ts
vendored
14
src/types/index.d.ts
vendored
@ -28,6 +28,11 @@ export type ConfirmEmailContainerDataType = {
|
||||
expired_seconds: number;
|
||||
};
|
||||
|
||||
export type OptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ProfilePageStatusType = 'update-password' | 'update-email' | 'confirm-email' | 'default';
|
||||
export type ProfilePageDataType = {
|
||||
/**
|
||||
@ -127,3 +132,12 @@ export type CreateUserParamsType = {
|
||||
password: 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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user