feat: edit lesson page

This commit is contained in:
Sergey Yarkov 2022-01-16 20:17:27 +03:00
parent 76f72dc9c4
commit 0dc80fd0e8
12 changed files with 251 additions and 61 deletions

View File

@ -1,21 +1,43 @@
import React from 'react'; import React, { useState } from 'react';
import * as helpres from '@educt/helpers'; import * as helpers from '@educt/helpers';
import { Stack, Flex, Box, Icon, Text, Input, IconButton, Button } from '@chakra-ui/react'; import { Stack, Flex, Box, Icon, Text, Input, IconButton, Button } from '@chakra-ui/react';
import { MdOutlineFilePresent } from 'react-icons/md'; import { MdAttachFile, MdOutlineFilePresent } from 'react-icons/md';
import { AddIcon, CloseIcon } from '@chakra-ui/icons'; import { CloseIcon } from '@chakra-ui/icons';
/** /**
* Hooks * Hooks
*/ */
import { useRef } from 'react'; import { useRef } from 'react';
import { FileSupportedFormatsEnum } from '@educt/enums'; import { FileSupportedFormatsEnum } from '@educt/enums';
import { AttachmentFileType } from '@educt/types';
type FilesUploaderPropsType = { type FilesUploaderPropsType = {
onChange: (files: File[]) => void; onChange: (files: File[]) => void;
files?: File[] | undefined; files?: File[] | undefined;
preloadedFiles?: AttachmentFileType[] | undefined;
}; };
const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files }) => { const FileContent: React.FC<{ name: string; size: number }> = ({ name, size }) => (
<Flex alignItems={'center'}>
<Icon as={MdOutlineFilePresent} mr='2' h={4} w={4} />
<Text overflow={'hidden'} fontSize={'sm'} fontWeight={'medium'} lineHeight={'1rem'}>
{name}
<br />
<Text as='small' color='gray.500'>
{helpers.transformBytes(size)}
</Text>
</Text>
</Flex>
);
const FileWrapper: React.FC = ({ children }) => (
<Flex alignItems={'center'} justifyContent={'space-between'} p='3' bg={'gray.50'} borderRadius={'lg'}>
{children}
</Flex>
);
const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files, preloadedFiles }) => {
const [preloaded, setPreloaded] = useState<AttachmentFileType[]>(preloadedFiles || []);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const onSelectFileHandler = () => { const onSelectFileHandler = () => {
@ -59,27 +81,30 @@ const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files }) =>
.map(ext => `.${ext.toLowerCase()}`) .map(ext => `.${ext.toLowerCase()}`)
.join(', ')} .join(', ')}
/> />
{preloadedFiles && files?.length === 0 && (
<Stack>
{preloaded.map((file, index) => (
<FileWrapper key={index}>
<FileContent name={file.name} size={file.size} />
<IconButton
onClick={() => {
setPreloaded(prev => prev.filter(file => file.size !== file.size));
onChange([]);
}}
aria-label='remove'
variant={'unstyled'}
size='xs'
icon={<CloseIcon />}
/>
</FileWrapper>
))}
</Stack>
)}
{files && ( {files && (
<Stack> <Stack>
{files.map((file, index) => ( {files.map((file, index) => (
<Flex <FileWrapper key={index}>
key={index} <FileContent name={file.name} size={file.size} />
alignItems={'center'}
justifyContent={'space-between'}
p='3'
bg={'gray.50'}
borderRadius={'lg'}
>
<Flex alignItems={'center'}>
<Icon as={MdOutlineFilePresent} mr='2' h={4} w={4} />
<Text overflow={'hidden'} fontSize={'sm'} fontWeight={'medium'} lineHeight={'1rem'}>
{file.name}
<br />
<Text as='small' color='gray.500'>
{helpres.transformBytes(file.size)}
</Text>
</Text>
</Flex>
<IconButton <IconButton
onClick={onRemoveSelectedFileHandler(file)} onClick={onRemoveSelectedFileHandler(file)}
aria-label='remove' aria-label='remove'
@ -87,12 +112,24 @@ const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files }) =>
size='xs' size='xs'
icon={<CloseIcon />} icon={<CloseIcon />}
/> />
</Flex> </FileWrapper>
))} ))}
</Stack> </Stack>
)} )}
<Button onClick={onSelectFileHandler} float={'right'} leftIcon={<AddIcon />} variant={'outline'} size='sm' mt='3'> {files?.length === 0 && preloaded?.length === 0 && (
Upload new <Text color={'gray.500'} userSelect={'none'} fontSize={'sm'} textAlign={'center'}>
No file selected
</Text>
)}
<Button
onClick={onSelectFileHandler}
float={'right'}
leftIcon={<MdAttachFile />}
variant={'outline'}
size='sm'
mt='3'
>
Select files
</Button> </Button>
</Box> </Box>
); );

View File

@ -7,7 +7,7 @@ import { LessonForm } from '.';
/** /**
* Validator * Validator
*/ */
import LessonFormSchema from './LessonForm.validator'; import { CreateLessonFormSchema } from './LessonForm.validator';
/** /**
* Hooks * Hooks
@ -16,7 +16,7 @@ import { useCreateLesson } from '@educt/hooks/queries';
import { useHistory, useParams } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
const CreateLessonForm: React.FC = () => { const CreateLessonForm: React.FC = () => {
const form = useForm<InputFields>({ resolver: yupResolver(LessonFormSchema) }); const form = useForm<InputFields>({ resolver: yupResolver(CreateLessonFormSchema) });
const { createLesson, isLoading } = useCreateLesson(); const { createLesson, isLoading } = useCreateLesson();
const { id: course_id } = useParams<{ id: string }>(); const { id: course_id } = useParams<{ id: string }>();
const history = useHistory(); const history = useHistory();

View File

@ -0,0 +1,62 @@
import React from 'react';
import * as helpers from '@educt/helpers';
import { yupResolver } from '@hookform/resolvers/yup';
import { SubmitHandler, useForm } from 'react-hook-form';
import type { InputFields } from './LessonForm';
import { LessonForm } from '.';
/**
* Validator
*/
import { EditLessonFormSchema } from './LessonForm.validator';
/**
* Hooks
*/
import { useHistory, useParams } from 'react-router-dom';
import { ILesson } from '@educt/interfaces';
type EditLessonFormPropsType = {
lesson: ILesson;
};
const EditLessonForm: React.FC<EditLessonFormPropsType> = ({ lesson }) => {
const form = useForm<InputFields>({
resolver: yupResolver(EditLessonFormSchema),
defaultValues: {
title: lesson.title,
description: lesson.description,
duration: lesson.duration,
video: undefined,
materials: undefined,
},
});
const { id: lesson_id } = useParams<{ id: string }>();
const history = useHistory();
const onSubmit: SubmitHandler<InputFields> = async data => {
const updatedFields = helpers.getDirtyFields(form.formState.dirtyFields, data);
console.log(updatedFields);
};
return (
<LessonForm
isLoading={false}
reactHookForm={{ ...form }}
onSubmit={form.handleSubmit(onSubmit)}
buttonLabel='Save'
preloadedVideo={lesson.video || undefined}
preloadedMaterials={lesson.materials.map(material => {
return {
name: material.client_name,
extname: material.ext,
size: material.size,
url: `materials/${material.name}`,
mimeType: '',
};
})}
/>
);
};
export default EditLessonForm;

View File

@ -17,6 +17,7 @@ import { Controller, UseFormReturn } from 'react-hook-form';
import VideoUploader from '@educt/components/VideoUploader'; import VideoUploader from '@educt/components/VideoUploader';
import FilesUploader from '@educt/components/FilesUploader'; import FilesUploader from '@educt/components/FilesUploader';
import { FileSupportedFormatsEnum, VideoSupportedFormatsEnum } from '@educt/enums'; import { FileSupportedFormatsEnum, VideoSupportedFormatsEnum } from '@educt/enums';
import type { AttachmentFileType, LessonVideoType } from '@educt/types';
export type InputFields = { export type InputFields = {
title: string; title: string;
@ -31,9 +32,18 @@ export type LessonFormPropsType = {
buttonLabel: string; buttonLabel: string;
isLoading: boolean; isLoading: boolean;
reactHookForm: UseFormReturn<InputFields>; reactHookForm: UseFormReturn<InputFields>;
preloadedVideo?: LessonVideoType | undefined;
preloadedMaterials?: AttachmentFileType[] | undefined;
}; };
const LessonForm: React.FC<LessonFormPropsType> = ({ onSubmit, buttonLabel, isLoading, reactHookForm }) => { const LessonForm: React.FC<LessonFormPropsType> = ({
onSubmit,
buttonLabel,
isLoading,
reactHookForm,
preloadedVideo,
preloadedMaterials,
}) => {
const { const {
control, control,
register, register,
@ -70,7 +80,7 @@ const LessonForm: React.FC<LessonFormPropsType> = ({ onSubmit, buttonLabel, isLo
control={control} control={control}
name='video' name='video'
render={({ field: { onChange, value: file } }) => ( render={({ field: { onChange, value: file } }) => (
<VideoUploader onChange={file => onChange(file)} file={file} /> <VideoUploader onChange={file => onChange(file)} file={file} preloadedVideoUrl={preloadedVideo?.url} />
)} )}
/> />
<Text as='small' color='red.500'> <Text as='small' color='red.500'>
@ -99,7 +109,11 @@ const LessonForm: React.FC<LessonFormPropsType> = ({ onSubmit, buttonLabel, isLo
control={control} control={control}
name='materials' name='materials'
render={({ field: { onChange, value: files } }) => ( render={({ field: { onChange, value: files } }) => (
<FilesUploader onChange={files => onChange(files)} files={Array.from(files || [])} /> <FilesUploader
onChange={files => onChange(files)}
files={Array.from(files || [])}
preloadedFiles={preloadedMaterials}
/>
)} )}
/> />
<Text as='small' color='red.500'> <Text as='small' color='red.500'>

View File

@ -4,7 +4,7 @@ import yup from '@educt/schema';
const VIDEO_FILE_SIZE = 5000 * 1024 * 1024; const VIDEO_FILE_SIZE = 5000 * 1024 * 1024;
const MATERIAL_FILE_SIZE = 100000 * 1024; const MATERIAL_FILE_SIZE = 100000 * 1024;
const LessonFormSchema = yup const CreateLessonFormSchema = yup
.object({ .object({
title: yup.string().required('Title field name is required').max(90, 'Title must be at most 90 characters'), title: yup.string().required('Title field name is required').max(90, 'Title must be at most 90 characters'),
description: yup description: yup
@ -13,28 +13,29 @@ const LessonFormSchema = yup
.max(250, 'Description must be at most 250 characters'), .max(250, 'Description must be at most 250 characters'),
duration: yup.string().required('Duration field name is required'), duration: yup.string().required('Duration field name is required'),
video: yup video: yup
.mixed() .mixed<File>()
.test( .test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value =>
'fileSize', !value ? true : value.size <= VIDEO_FILE_SIZE
`File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`,
(value: File) => {
if (!value) return true;
return value && value.size <= VIDEO_FILE_SIZE;
}
) )
.required('Video field name is required'), .required('Video field name is required'),
materials: yup materials: yup
.mixed() .mixed<File[]>()
.test( .test(
'fileSize', 'fileSize',
`File size is too large, maximum size is ${helpers.transformBytes(MATERIAL_FILE_SIZE)}`, `File size is too large, maximum size is ${helpers.transformBytes(MATERIAL_FILE_SIZE)}`,
(values: File[]) => { values => (!values ? true : values.every(value => value.size <= MATERIAL_FILE_SIZE))
if (!values) return true;
return values && values.every(value => value.size <= MATERIAL_FILE_SIZE);
}
) )
.optional(), .optional(),
}) })
.required(); .required();
export default LessonFormSchema; const EditLessonFormSchema = CreateLessonFormSchema.shape({
video: yup
.mixed<File>()
.test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value =>
!value ? true : value.size <= VIDEO_FILE_SIZE
)
.optional(),
});
export { CreateLessonFormSchema, EditLessonFormSchema };

View File

@ -2,16 +2,19 @@ import React, { useEffect, useRef, useState } from 'react';
import { Flex, Box, Icon, Text, Button, Input, AspectRatio, useColorModeValue } from '@chakra-ui/react'; import { Flex, Box, Icon, Text, Button, Input, AspectRatio, useColorModeValue } from '@chakra-ui/react';
import { MdUpload } from 'react-icons/md'; import { MdUpload } from 'react-icons/md';
import { VideoSupportedFormatsEnum } from '@educt/enums'; import { VideoSupportedFormatsEnum } from '@educt/enums';
import { LessonVideoType } from '@educt/types';
type VideoUploaderPropsType = { type VideoUploaderPropsType = {
onChange: (file: File) => void; onChange: (file: File) => void;
file?: File | undefined; file?: File | LessonVideoType | undefined;
preloadedVideoUrl?: string | undefined;
}; };
const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) => { const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file, preloadedVideoUrl }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [videoObjectUrl, setVideoObjectUrl] = useState<string | undefined>(undefined); const [videoObjectUrl, setVideoObjectUrl] = useState<string | undefined>(undefined);
const isPreloadedVideo = preloadedVideoUrl !== undefined;
const onSelectFileHandler = () => { const onSelectFileHandler = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
@ -38,12 +41,24 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
} }
}; };
const ChangeVideoButton = () => (
<Button onClick={onSelectFileHandler} leftIcon={<MdUpload />} variant={'outline'} size='sm' mt='2' float='right'>
Change video
</Button>
);
useEffect(() => { useEffect(() => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.load(); videoRef.current.load();
} }
}, [videoObjectUrl]); }, [videoObjectUrl]);
useEffect(() => {
if (file && file instanceof File) {
setVideoObjectUrl(URL.createObjectURL(file));
}
}, []);
return ( return (
<Box> <Box>
{file && videoObjectUrl ? ( {file && videoObjectUrl ? (
@ -53,18 +68,9 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
<source src={videoObjectUrl} /> <source src={videoObjectUrl} />
</video> </video>
</AspectRatio> </AspectRatio>
<Button <ChangeVideoButton />
onClick={onSelectFileHandler}
leftIcon={<MdUpload />}
variant={'outline'}
size='sm'
mt='2'
float='right'
>
Change video
</Button>
</Box> </Box>
) : ( ) : !isPreloadedVideo ? (
<Flex <Flex
p='40px 10px' p='40px 10px'
textAlign={'center'} textAlign={'center'}
@ -89,6 +95,15 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
Select file Select file
</Button> </Button>
</Flex> </Flex>
) : (
<Box>
<AspectRatio maxH='lg' mt='2'>
<video controls style={{ borderRadius: '5px' }}>
<source src={preloadedVideoUrl} />
</video>
</AspectRatio>
<ChangeVideoButton />
</Box>
)} )}
<Input <Input
type='file' type='file'
@ -97,6 +112,7 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
maxW={'0'} maxW={'0'}
h='0' h='0'
display={'block'} display={'block'}
required={false}
opacity={'0'} opacity={'0'}
ml='auto' ml='auto'
mr='auto' mr='auto'

View File

@ -80,6 +80,11 @@ export function transformBytes(bytes: number, decimals = 2): string {
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
} }
export function transformBlobToFile(blob: Blob, fileName: string) {
const file = new File([blob], fileName, { type: blob.type });
return file;
}
/** /**
* *
* @param array Array * @param array Array

View File

@ -6,6 +6,7 @@ import { CourseStatusEnum } from '@educt/enums';
/** /**
* Hooks * Hooks
*/ */
import { useEffect } from 'react';
import { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
export const CoursesTabs: React.FC<BoxProps> = props => { export const CoursesTabs: React.FC<BoxProps> = props => {
@ -14,6 +15,10 @@ export const CoursesTabs: React.FC<BoxProps> = props => {
pageStore: { coursesStore }, pageStore: { coursesStore },
} = useRootStore(); } = useRootStore();
useEffect(() => {
coursesStore.setShowedStatus(undefined);
}, []);
return ( return (
me && me &&
(me.isAdmin || me.isTeacher ? ( (me.isAdmin || me.isTeacher ? (

View File

@ -156,8 +156,9 @@ const LessonPage: React.FC<IPageProps> = () => {
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}> <Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}>
<GridItem> <GridItem>
<Box> <Box>
{lesson.video ? (
<ReactPlayer width={'100%'} height={'100%'} controls url={lesson.video?.url} /> <ReactPlayer width={'100%'} height={'100%'} controls url={lesson.video?.url} />
{!lesson.video && ( ) : (
<Flex h='500px' justifyContent={'center'} alignItems={'center'}> <Flex h='500px' justifyContent={'center'} alignItems={'center'}>
<Text color='gray.500' userSelect={'none'}> <Text color='gray.500' userSelect={'none'}>
Video has not been uploaded yet. Video has not been uploaded yet.

View File

@ -0,0 +1,32 @@
import React from 'react';
import { IPageProps } from '@educt/interfaces';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Redirect, useParams } from 'react-router-dom';
import LoadingPage from '@educt/components/LoadingPage';
import { useFetchLesson } from '@educt/hooks/queries/lesson/useFetchLesson';
import EditLessonForm from '@educt/components/Forms/LessonForm/EditLessonForm';
/**
* Create Lesson page
*/
const EditLessonPage: React.FC<IPageProps> = () => {
const params = useParams<{ id: string }>();
const { error, data: lesson, isLoading } = useFetchLesson(params.id);
if (error?.response?.status === 404) {
return <Redirect to='/404' />;
}
if (!lesson || isLoading) return <LoadingPage />;
return (
<PageWrapper>
<PageHeading heading='Lesson Editor' description={lesson.description} />
<PageContent>
<EditLessonForm lesson={lesson} />
</PageContent>
</PageWrapper>
);
};
export default EditLessonPage;

View File

@ -8,6 +8,7 @@ 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 CreateLessonPage from '@educt/pages/courses/create-lesson/create-lesson'; import CreateLessonPage from '@educt/pages/courses/create-lesson/create-lesson';
import EditLessonPage from './pages/lessons/edit-lesson/edit-lesson';
import CoursePage from '@educt/pages/course/course'; import CoursePage from '@educt/pages/course/course';
import EditCoursePage from '@educt/pages/courses/edit/edit'; 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';
@ -78,6 +79,14 @@ export const routes: IRouteOption[] = [
exact: true, exact: true,
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER], roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
}, },
{
path: '/lessons/edit/:id',
title: 'Edit Lesson',
component: EditLessonPage,
isPrivate: true,
exact: true,
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
},
{ {
path: '/lesson/:id', path: '/lesson/:id',
title: 'Lesson', title: 'Lesson',

View File

@ -88,6 +88,7 @@ class CourseService {
} }
/** /**
* Create new course
* *
* @param data Data for creating course * @param data Data for creating course
* @returns * @returns
@ -104,6 +105,13 @@ class CourseService {
return result.data; return result.data;
} }
/**
* Update course
*
* @param id Course id
* @param data Data to update
* @returns Updated course
*/
public async update( public async update(
id: string, id: string,
data: UpdateCourseParamsType data: UpdateCourseParamsType