mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
refactor: editing base fields of course
This commit is contained in:
parent
cd2af7eae1
commit
aa351d1c15
@ -1,15 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { useToast, Flex, Box, Heading, Text } from '@chakra-ui/react';
|
import { Flex, Box, Heading, Text } from '@chakra-ui/react';
|
||||||
import { MdError } from 'react-icons/md';
|
import { MdError } from 'react-icons/md';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useToast } from '@chakra-ui/toast';
|
||||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||||
|
import { Redirect } from 'react-router';
|
||||||
|
|
||||||
const ErrorFallback: React.FC<{ error: any }> = props => {
|
const ErrorFallback: React.FC<{ error: any }> = props => {
|
||||||
const { userStore, authStore } = useRootStore();
|
const { userStore, authStore } = useRootStore();
|
||||||
const { error } = props;
|
const { error } = props;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
toast({ title: `${error.message}`, duration: 10000, isClosable: true, status: 'error' });
|
toast({ title: `${error.message}`, duration: 10000, isClosable: true, status: 'error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -18,7 +25,7 @@ const ErrorFallback: React.FC<{ error: any }> = props => {
|
|||||||
userStore.me = null;
|
userStore.me = null;
|
||||||
authStore.setIsLoggedIn(false);
|
authStore.setIsLoggedIn(false);
|
||||||
Cookies.remove('logged_in');
|
Cookies.remove('logged_in');
|
||||||
}
|
} else if (error.response.status === 404) return <Redirect to='/404' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
210
src/components/Forms/CourseForm/CourseForm.tsx
Normal file
210
src/components/Forms/CourseForm/CourseForm.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Textarea, InputGroup, InputRightElement, Input } from '@chakra-ui/react';
|
||||||
|
import { FormControl, FormLabel, FormHelperText } from '@chakra-ui/form-control';
|
||||||
|
import { Flex, Box, Text, Divider, Stack } from '@chakra-ui/layout';
|
||||||
|
import { Controller, UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types
|
||||||
|
*/
|
||||||
|
import { OptionType } from '@educt/types';
|
||||||
|
import { UserRoleEnum } from '@educt/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Components
|
||||||
|
*/
|
||||||
|
import AsyncSelect from '@educt/components/AsyncSelect';
|
||||||
|
import FileSelect from '@educt/components/FileSelect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks
|
||||||
|
*/
|
||||||
|
import { useToast } from '@chakra-ui/toast';
|
||||||
|
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||||
|
import { useErrorHandler } from 'react-error-boundary';
|
||||||
|
|
||||||
|
export type InputFields = {
|
||||||
|
title: string;
|
||||||
|
image?: File | undefined;
|
||||||
|
category_id: string;
|
||||||
|
description: string;
|
||||||
|
teacher_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseFormPropsType = {
|
||||||
|
onSubmit: () => Promise<void>;
|
||||||
|
buttonLabel: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
reactHookForm: UseFormReturn<InputFields>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLoading, reactHookForm }) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = reactHookForm;
|
||||||
|
|
||||||
|
const {
|
||||||
|
userStore: { userService },
|
||||||
|
categoryStore: { categoryService },
|
||||||
|
} = useRootStore();
|
||||||
|
const handleError = useErrorHandler();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load users into select field
|
||||||
|
*/
|
||||||
|
const loadUsersOptions = async (): Promise<OptionType[] | undefined> => {
|
||||||
|
try {
|
||||||
|
const users = await userService.fetchAll({ limit: 12, role: UserRoleEnum.TEACHER });
|
||||||
|
|
||||||
|
return users.data.map(user => ({
|
||||||
|
label: user.fullname,
|
||||||
|
value: user.id,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
toast({ title: 'Error' });
|
||||||
|
} else {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load categories into select filed
|
||||||
|
*/
|
||||||
|
const loadCategoriesOptions = async (): Promise<OptionType[] | undefined> => {
|
||||||
|
try {
|
||||||
|
const categories = await categoryService.fetchAll();
|
||||||
|
return categories.data.map(category => ({
|
||||||
|
label: category.title,
|
||||||
|
value: category.id,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
toast({ title: 'Error' });
|
||||||
|
} else {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const watchTitle = watch('title');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<Stack spacing='5'>
|
||||||
|
<FormControl isRequired id='title' isInvalid={!!errors.title}>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<Input pr='60px' size='md' placeholder='Course name' type='text' {...register('title')} />
|
||||||
|
<InputRightElement
|
||||||
|
mr='2'
|
||||||
|
children={
|
||||||
|
<Text as='small' color={!!errors.title || watchTitle?.length > 90 ? 'red.500' : 'gray.500'}>
|
||||||
|
{watchTitle?.length || 0}/90
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
<FormHelperText>e.g Web Development in Javascript</FormHelperText>
|
||||||
|
<Text as='small' color='red.500'>
|
||||||
|
{errors.title?.message}
|
||||||
|
</Text>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isRequired id='description' isInvalid={!!errors.description}>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<Textarea resize='none' minH='150px' placeholder='Course description...' {...register('description')} />
|
||||||
|
<Text as='small' color='red.500'>
|
||||||
|
{errors.description?.message}
|
||||||
|
</Text>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Flex justifyContent='space-between' flexDirection={{ base: 'column', lg: 'row' }}>
|
||||||
|
<Box flexGrow={1} flexBasis='0' mr={{ base: '0', lg: '7' }}>
|
||||||
|
<Stack spacing='5'>
|
||||||
|
<FormControl isRequired id='category_id' isInvalid={!!errors.category_id}>
|
||||||
|
<FormLabel>Category</FormLabel>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='category_id'
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<AsyncSelect
|
||||||
|
loadOptions={loadCategoriesOptions}
|
||||||
|
onChange={selected => onChange(selected)}
|
||||||
|
value={value}
|
||||||
|
placeholder='Select Category'
|
||||||
|
noOptionsMessage='There are no categories.'
|
||||||
|
loadingText='Wait...'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Text as='small' color='red.500'>
|
||||||
|
{errors.category_id?.message}
|
||||||
|
</Text>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isRequired id='teacher_id' isInvalid={!!errors.teacher_id}>
|
||||||
|
<FormLabel>Author</FormLabel>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='teacher_id'
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<AsyncSelect
|
||||||
|
loadOptions={loadUsersOptions}
|
||||||
|
onChange={selected => onChange(selected)}
|
||||||
|
value={value}
|
||||||
|
placeholder='Select Author'
|
||||||
|
noOptionsMessage='There are no teachers.'
|
||||||
|
loadingText='Wait...'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Text as='small' color='red.500'>
|
||||||
|
{errors.teacher_id?.message}
|
||||||
|
</Text>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexGrow={0} flexBasis='50%' mt={{ base: '5', lg: '0' }}>
|
||||||
|
<FormControl id='image' isInvalid={!!errors.image}>
|
||||||
|
<FormLabel>Image</FormLabel>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='image'
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<FileSelect onChange={file => onChange(file)} supportedFormats={['JPG', 'JPEG', 'PNG', 'WEBP']} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Text as='small' color='red.500'>
|
||||||
|
{errors.image?.message}
|
||||||
|
</Text>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider mt='5' />
|
||||||
|
|
||||||
|
<Box mt='5'>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={!isDirty}
|
||||||
|
loadingText='Saving...'
|
||||||
|
type='submit'
|
||||||
|
colorScheme='blue'
|
||||||
|
variant='outline'
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseForm;
|
||||||
91
src/components/Forms/CourseForm/CreateCourseForm.tsx
Normal file
91
src/components/Forms/CourseForm/CreateCourseForm.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@chakra-ui/layout';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { SubmitHandler } from 'react-hook-form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types
|
||||||
|
*/
|
||||||
|
import { InputFields } from './CourseForm';
|
||||||
|
import { CourseStatusEnum } from '@educt/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Components
|
||||||
|
*/
|
||||||
|
import CourseForm from './CourseForm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useToast } from '@chakra-ui/toast';
|
||||||
|
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||||
|
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
|
||||||
|
import { useErrorHandler } from 'react-error-boundary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema
|
||||||
|
*/
|
||||||
|
import CourseFormSchema from './CourseForm.validator';
|
||||||
|
|
||||||
|
type CreateFormCoursePropsType = {};
|
||||||
|
|
||||||
|
const CreateFormCourse: React.FC<CreateFormCoursePropsType> = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const {
|
||||||
|
courseStore: { courseService },
|
||||||
|
} = useRootStore();
|
||||||
|
const form = useForm<InputFields>({ resolver: yupResolver(CourseFormSchema) });
|
||||||
|
const isMountedRef = useIsMountedRef();
|
||||||
|
const history = useHistory();
|
||||||
|
const toast = useToast();
|
||||||
|
const handleError = useErrorHandler();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit handler
|
||||||
|
*/
|
||||||
|
const onSubmit: SubmitHandler<InputFields> = async data => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const course = await courseService.create({
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
teacher_id: data.teacher_id,
|
||||||
|
category_id: data.category_id,
|
||||||
|
image: data.image,
|
||||||
|
status: CourseStatusEnum.DRAFT,
|
||||||
|
});
|
||||||
|
toast({ title: `Course successfully created.`, status: 'success' });
|
||||||
|
history.push(`/courses/edit/${course.data.id}`);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<CourseForm
|
||||||
|
reactHookForm={{ ...form }}
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
buttonLabel='Create'
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateFormCourse;
|
||||||
108
src/components/Forms/CourseForm/EditCourseForm.tsx
Normal file
108
src/components/Forms/CourseForm/EditCourseForm.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box } from '@chakra-ui/layout';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { SubmitHandler } from 'react-hook-form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types
|
||||||
|
*/
|
||||||
|
import { InputFields } from './CourseForm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Components
|
||||||
|
*/
|
||||||
|
import CourseForm from './CourseForm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||||
|
import { useErrorHandler } from 'react-error-boundary';
|
||||||
|
import { useToast } from '@chakra-ui/toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema
|
||||||
|
*/
|
||||||
|
import CourseFormSchema from './CourseForm.validator';
|
||||||
|
|
||||||
|
type EditFormCoursePropsType = {
|
||||||
|
defaultValues?:
|
||||||
|
| {
|
||||||
|
title?: string | undefined;
|
||||||
|
image?: File | undefined;
|
||||||
|
category_id?: string | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
teacher_id?: string | undefined;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditFormCourse: React.FC<EditFormCoursePropsType> = ({ defaultValues }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const {
|
||||||
|
courseStore: { courseService },
|
||||||
|
} = useRootStore();
|
||||||
|
const form = useForm<InputFields>({
|
||||||
|
resolver: yupResolver(CourseFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: defaultValues?.title || '',
|
||||||
|
description: defaultValues?.description || '',
|
||||||
|
category_id: defaultValues?.category_id || '',
|
||||||
|
teacher_id: defaultValues?.teacher_id || '',
|
||||||
|
image: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const toast = useToast();
|
||||||
|
const handleError = useErrorHandler();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit handler
|
||||||
|
*/
|
||||||
|
const onSubmit: SubmitHandler<InputFields> = async data => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const course = await courseService.update(id, {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
teacher_id: data.teacher_id,
|
||||||
|
category_id: data.category_id,
|
||||||
|
});
|
||||||
|
toast({ title: `Course updated.`, status: 'info' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update fields with new values
|
||||||
|
*/
|
||||||
|
form.reset({
|
||||||
|
title: course.data.title,
|
||||||
|
description: course.data.description,
|
||||||
|
teacher_id: course.data.teacher.id,
|
||||||
|
category_id: course.data.category.id,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response) {
|
||||||
|
toast({ title: `${error.message}`, status: 'error' });
|
||||||
|
} else {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<CourseForm
|
||||||
|
reactHookForm={{ ...form }}
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
buttonLabel='Save'
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditFormCourse;
|
||||||
4
src/components/Forms/CourseForm/index.ts
Normal file
4
src/components/Forms/CourseForm/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import CreateCourseForm from './CreateCourseForm';
|
||||||
|
import EditCourseForm from './EditCourseForm';
|
||||||
|
|
||||||
|
export { CreateCourseForm, EditCourseForm };
|
||||||
49
src/hooks/useFetchCourseQuery.ts
Normal file
49
src/hooks/useFetchCourseQuery.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ICourse } from '@educt/interfaces';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useErrorHandler } from 'react-error-boundary';
|
||||||
|
import { useRootStore } from './useRootStore';
|
||||||
|
|
||||||
|
type CourseStateType = {
|
||||||
|
data: Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'> | null;
|
||||||
|
error: any;
|
||||||
|
loading: boolean;
|
||||||
|
fetched: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFetchCourseQuery = (id: string) => {
|
||||||
|
const {
|
||||||
|
courseStore: { courseService },
|
||||||
|
} = useRootStore();
|
||||||
|
const [state, setState] = useState<CourseStateType>({ data: null, error: null, loading: false, fetched: false });
|
||||||
|
const handleError = useErrorHandler();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch course handler
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
setState(s => ({ ...s, loading: true }));
|
||||||
|
courseService
|
||||||
|
.fetchById(id)
|
||||||
|
.then(data => {
|
||||||
|
setState(s => ({ ...s, data: data.data }));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error.response) {
|
||||||
|
setState(s => ({ ...s, error }));
|
||||||
|
} else {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setState(s => ({ ...s, loading: false, fetched: true }));
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return { ...state };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFetchCourseQuery;
|
||||||
@ -21,6 +21,7 @@ import CourseStatusBadge from '@educt/components/CourseStatusBadge';
|
|||||||
/**
|
/**
|
||||||
* Hooks
|
* Hooks
|
||||||
*/
|
*/
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
import { useColorMode } from '@chakra-ui/color-mode';
|
import { useColorMode } from '@chakra-ui/color-mode';
|
||||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||||
|
|
||||||
@ -35,6 +36,7 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
|
|||||||
userStore: { me },
|
userStore: { me },
|
||||||
} = useRootStore();
|
} = useRootStore();
|
||||||
const { colorMode } = useColorMode();
|
const { colorMode } = useColorMode();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
if (me === null) return null;
|
if (me === null) return null;
|
||||||
|
|
||||||
@ -54,7 +56,9 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
|
|||||||
_active={{ backgroundColor: colorMode === 'dark' ? 'gray.600' : 'gray.200' }}
|
_active={{ backgroundColor: colorMode === 'dark' ? 'gray.600' : 'gray.200' }}
|
||||||
/>
|
/>
|
||||||
<MenuList>
|
<MenuList>
|
||||||
<MenuItem icon={<EditIcon />}>Edit course</MenuItem>
|
<MenuItem onClick={() => history.push(`/courses/edit/${course.id}`)} icon={<EditIcon />}>
|
||||||
|
Edit
|
||||||
|
</MenuItem>
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (course.status) {
|
switch (course.status) {
|
||||||
case CourseStatusEnum.DRAFT:
|
case CourseStatusEnum.DRAFT:
|
||||||
|
|||||||
@ -1,266 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Grid,
|
|
||||||
useToast,
|
|
||||||
Text,
|
|
||||||
Flex,
|
|
||||||
Stack,
|
|
||||||
Button,
|
|
||||||
Textarea,
|
|
||||||
FormControl,
|
|
||||||
FormLabel,
|
|
||||||
InputGroup,
|
|
||||||
InputRightElement,
|
|
||||||
Input,
|
|
||||||
FormHelperText,
|
|
||||||
Divider,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { SubmitHandler, useForm, useWatch, Controller } from 'react-hook-form';
|
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
|
||||||
import AsyncSelect from '@educt/components/AsyncSelect';
|
|
||||||
import FileSelect from '@educt/components/FileSelect';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Types
|
|
||||||
*/
|
|
||||||
import { OptionType } from '@educt/types';
|
|
||||||
import { CourseStatusEnum, UserRoleEnum } from '@educt/enums';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hooks
|
|
||||||
*/
|
|
||||||
import { useHistory } from 'react-router';
|
|
||||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
|
||||||
import { useErrorHandler } from 'react-error-boundary';
|
|
||||||
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schema
|
|
||||||
*/
|
|
||||||
import CreateCourseSchema from './CreateCourseForm.validator';
|
|
||||||
|
|
||||||
type CreateCourseInputType = {
|
|
||||||
title: string;
|
|
||||||
image: File;
|
|
||||||
category_id: string;
|
|
||||||
description: string;
|
|
||||||
teacher_id: string;
|
|
||||||
};
|
|
||||||
type CreateCourseFormPropsType = {};
|
|
||||||
|
|
||||||
const CreateCourseForm: React.FC<CreateCourseFormPropsType> = () => {
|
|
||||||
const {
|
|
||||||
userStore: { userService },
|
|
||||||
categoryStore: { categoryService },
|
|
||||||
courseStore: { courseService },
|
|
||||||
} = useRootStore();
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
control,
|
|
||||||
} = 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 (): Promise<OptionType[] | undefined> => {
|
|
||||||
try {
|
|
||||||
const users = await userService.fetchAll({ limit: 12, role: UserRoleEnum.TEACHER });
|
|
||||||
|
|
||||||
return users.data.map(user => ({
|
|
||||||
label: user.fullname,
|
|
||||||
value: user.id,
|
|
||||||
}));
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response) {
|
|
||||||
toast({ title: 'Error' });
|
|
||||||
} else {
|
|
||||||
handleError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load categories into select filed
|
|
||||||
*/
|
|
||||||
const loadCategoriesOptions = async (): Promise<OptionType[] | undefined> => {
|
|
||||||
try {
|
|
||||||
const categories = await categoryService.fetchAll();
|
|
||||||
return categories.data.map(category => ({
|
|
||||||
label: category.title,
|
|
||||||
value: category.id,
|
|
||||||
}));
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response) {
|
|
||||||
toast({ title: 'Error' });
|
|
||||||
} else {
|
|
||||||
handleError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit form handler
|
|
||||||
*/
|
|
||||||
const onSubmit: SubmitHandler<CreateCourseInputType> = async 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,
|
|
||||||
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)}>
|
|
||||||
<Stack spacing='5'>
|
|
||||||
<FormControl isRequired id='title' isInvalid={!!errors.title}>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<InputGroup>
|
|
||||||
<Input pr='60px' size='md' placeholder='Course name' type='text' {...register('title')} />
|
|
||||||
<InputRightElement
|
|
||||||
mr='2'
|
|
||||||
children={
|
|
||||||
<Text as='small' color={!!errors.title || title.length > 90 ? 'red.500' : 'gray.500'}>
|
|
||||||
{title.length}/90
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
<FormHelperText>e.g Web Development in Javascript</FormHelperText>
|
|
||||||
<Text as='small' color='red.500'>
|
|
||||||
{errors.title?.message}
|
|
||||||
</Text>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl isRequired id='description' isInvalid={!!errors.description}>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<Textarea resize='none' minH='150px' placeholder='Course description...' {...register('description')} />
|
|
||||||
<Text as='small' color='red.500'>
|
|
||||||
{errors.description?.message}
|
|
||||||
</Text>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Flex justifyContent='space-between' flexDirection={{ base: 'column', lg: 'row' }}>
|
|
||||||
<Box flexGrow={1} flexBasis='0' mr={{ base: '0', lg: '7' }}>
|
|
||||||
<Stack spacing='5'>
|
|
||||||
<FormControl isRequired id='category_id' isInvalid={!!errors.category_id}>
|
|
||||||
<FormLabel>Category</FormLabel>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name='category_id'
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<AsyncSelect
|
|
||||||
loadOptions={loadCategoriesOptions}
|
|
||||||
onChange={selected => onChange(selected)}
|
|
||||||
value={value}
|
|
||||||
placeholder='Select Category'
|
|
||||||
noOptionsMessage='There are no categories.'
|
|
||||||
loadingText='Wait...'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Text as='small' color='red.500'>
|
|
||||||
{errors.category_id?.message}
|
|
||||||
</Text>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl isRequired id='teacher_id' isInvalid={!!errors.teacher_id}>
|
|
||||||
<FormLabel>Author</FormLabel>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name='teacher_id'
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<AsyncSelect
|
|
||||||
loadOptions={loadUsersOptions}
|
|
||||||
onChange={selected => onChange(selected)}
|
|
||||||
value={value}
|
|
||||||
placeholder='Select Author'
|
|
||||||
noOptionsMessage='There are no teachers.'
|
|
||||||
loadingText='Wait...'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Text as='small' color='red.500'>
|
|
||||||
{errors.teacher_id?.message}
|
|
||||||
</Text>
|
|
||||||
</FormControl>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box flexGrow={0} flexBasis='50%' mt={{ base: '5', lg: '0' }}>
|
|
||||||
<FormControl id='image' isInvalid={!!errors.image}>
|
|
||||||
<FormLabel>Image</FormLabel>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name='image'
|
|
||||||
render={({ field: { onChange } }) => (
|
|
||||||
<FileSelect onChange={file => onChange(file)} supportedFormats={['JPG', 'JPEG', 'PNG', 'WEBP']} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Text as='small' color='red.500'>
|
|
||||||
{errors.image?.message}
|
|
||||||
</Text>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Divider mt='5' />
|
|
||||||
|
|
||||||
<Box mt='5'>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingText='Saving...'
|
|
||||||
type='submit'
|
|
||||||
colorScheme='blue'
|
|
||||||
onClick={() => setStatus(CourseStatusEnum.PUBLISHED)}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</form>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateCourseForm;
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
import CreateCourseForm from './CreateCourseForm';
|
|
||||||
|
|
||||||
export default CreateCourseForm;
|
|
||||||
@ -10,7 +10,7 @@ import { IPageProps } from '@educt/interfaces';
|
|||||||
* Components
|
* Components
|
||||||
*/
|
*/
|
||||||
import PrevPageButton from '@educt/components/PrevPageButton';
|
import PrevPageButton from '@educt/components/PrevPageButton';
|
||||||
import CreateCourseForm from './components';
|
import { CreateCourseForm } from '@educt/components/Forms/CourseForm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create course page
|
* Create course page
|
||||||
|
|||||||
87
src/pages/courses/edit/edit.tsx
Normal file
87
src/pages/courses/edit/edit.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Helmet from 'react-helmet';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
import { Box, Heading, Text, Tab, TabList, Tabs, TabPanels, TabPanel, Button } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types
|
||||||
|
*/
|
||||||
|
import { IPageProps } from '@educt/interfaces';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Components
|
||||||
|
*/
|
||||||
|
import LoadingPage from '@educt/components/LoadingPage';
|
||||||
|
import { EditCourseForm } from '@educt/components/Forms/CourseForm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks
|
||||||
|
*/
|
||||||
|
import useFetchCourseQuery from '@educt/hooks/useFetchCourseQuery';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Course editor
|
||||||
|
*/
|
||||||
|
const EditCoursePage: React.FC<IPageProps> = () => {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const { data: course, loading, error } = useFetchCourseQuery(params.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not Found
|
||||||
|
*/
|
||||||
|
if (error?.response?.status === 404) return <Redirect to='/404' />;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading
|
||||||
|
*/
|
||||||
|
if (loading || course === null) return <LoadingPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>Editing {course.title}</title>
|
||||||
|
</Helmet>
|
||||||
|
<Box>
|
||||||
|
<Heading as='h1'>Course editor</Heading>
|
||||||
|
<Text mt='2'>{course.title}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Tabs isLazy mt='8'>
|
||||||
|
<TabList>
|
||||||
|
<Tab>Info</Tab>
|
||||||
|
<Tab>Lessons</Tab>
|
||||||
|
<Tab>Students</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel>
|
||||||
|
<EditCourseForm
|
||||||
|
defaultValues={{
|
||||||
|
title: course.title,
|
||||||
|
description: course.description,
|
||||||
|
category_id: course.category.id,
|
||||||
|
teacher_id: course.teacher.id,
|
||||||
|
image: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
{course.lessons.length === 0 ? (
|
||||||
|
<Box textAlign='center' mt='10'>
|
||||||
|
<Text>No lessons have been added to this course yet</Text>
|
||||||
|
<Button mt='3' size='sm' colorScheme='blue' variant='outline'>
|
||||||
|
Create new lesson
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel></TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditCoursePage;
|
||||||
@ -69,7 +69,6 @@ const UpdateUserContactsForm: React.FC<UpdateUserContactsFormPropsType> = ({ con
|
|||||||
telegram_id: data.telegram_id ?? '',
|
telegram_id: data.telegram_id ?? '',
|
||||||
vk_id: data.vk_id ?? '',
|
vk_id: data.vk_id ?? '',
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { IPrivateRouteProps } from './components/PrivateRoute/PrivateRoute';
|
|||||||
import AuthPage from '@educt/pages/auth/auth';
|
import AuthPage from '@educt/pages/auth/auth';
|
||||||
import NotFoundPage from '@educt/pages/404/404';
|
import NotFoundPage from '@educt/pages/404/404';
|
||||||
import CreateCoursePage from '@educt/pages/courses/create/create';
|
import CreateCoursePage from '@educt/pages/courses/create/create';
|
||||||
|
import EditCoursePage from '@educt/pages/courses/edit/edit';
|
||||||
import ChangePasswordPage from '@educt/pages/profile/change-password/change-password';
|
import ChangePasswordPage from '@educt/pages/profile/change-password/change-password';
|
||||||
import ChangeEmailPage from '@educt/pages/profile/change-email/change-email';
|
import ChangeEmailPage from '@educt/pages/profile/change-email/change-email';
|
||||||
import { UserRoleEnum } from './enums';
|
import { UserRoleEnum } from './enums';
|
||||||
@ -43,6 +44,14 @@ export const routes: IRouteOption[] = [
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
|
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/courses/edit/:id',
|
||||||
|
title: 'Course editor',
|
||||||
|
component: EditCoursePage,
|
||||||
|
isPrivate: true,
|
||||||
|
exact: true,
|
||||||
|
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/lessons',
|
path: '/lessons',
|
||||||
title: 'Lessons',
|
title: 'Lessons',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import * as helpers from '@educt/helpers';
|
import * as helpers from '@educt/helpers';
|
||||||
import { IApiRespose, ICourse } from '@educt/interfaces';
|
import { IApiRespose, ICourse } from '@educt/interfaces';
|
||||||
import { CreateCourseParamsType, FetchCoursesParams } from '@educt/types';
|
import { CreateCourseParamsType, FetchCoursesParams, UpdateCourseParamsType } from '@educt/types';
|
||||||
import { CourseStatusEnum } from '@educt/enums';
|
import { CourseStatusEnum } from '@educt/enums';
|
||||||
|
|
||||||
export class CourseService {
|
export class CourseService {
|
||||||
@ -28,6 +28,19 @@ export class CourseService {
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch course by id param
|
||||||
|
*
|
||||||
|
* @param id Course id
|
||||||
|
* @returns Course
|
||||||
|
*/
|
||||||
|
public async fetchById(
|
||||||
|
id: string
|
||||||
|
): Promise<IApiRespose<Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>>> {
|
||||||
|
const result = await this.api.get(`/v1/courses/${id}`);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update status of course by id
|
* Update status of course by id
|
||||||
*
|
*
|
||||||
@ -59,6 +72,15 @@ export class CourseService {
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
id: string,
|
||||||
|
data: UpdateCourseParamsType
|
||||||
|
): Promise<IApiRespose<Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>>> {
|
||||||
|
const result = await this.api.patch(`/v1/courses/${id}`, data);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete course by id
|
* Delete course by id
|
||||||
*
|
*
|
||||||
|
|||||||
11
src/types/index.d.ts
vendored
11
src/types/index.d.ts
vendored
@ -135,6 +135,15 @@ export type CreateCourseParamsType = {
|
|||||||
description: string;
|
description: string;
|
||||||
teacher_id: string;
|
teacher_id: string;
|
||||||
category_id: string;
|
category_id: string;
|
||||||
image: File | undefined;
|
image?: File | undefined;
|
||||||
status: CourseStatusEnum;
|
status: CourseStatusEnum;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateCourseParamsType = {
|
||||||
|
title?: string | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
teacher_id?: string | undefined;
|
||||||
|
category_id?: string | undefined;
|
||||||
|
image?: File | undefined;
|
||||||
|
status?: CourseStatusEnum | undefined;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user