mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 18:29:15 +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'
|
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'
|
||||||
|
|||||||
@ -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)': {
|
||||||
|
|||||||
@ -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,18 +51,42 @@ 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 => ({
|
|
||||||
|
// TODO: remove filter later
|
||||||
|
return users.data
|
||||||
|
.filter(user => helpers.userContainRoles(user.roles, [UserRoleEnum.TEACHER]))
|
||||||
|
.map(user => ({
|
||||||
label: user.fullname,
|
label: user.fullname,
|
||||||
value: user.id,
|
value: user.id,
|
||||||
}));
|
}));
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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
14
src/types/index.d.ts
vendored
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user