feat: refactor layout & added file select component

This commit is contained in:
Sergey Yarkov 2021-11-05 17:39:50 +03:00
parent eb33113912
commit ff88ad75a3
14 changed files with 167 additions and 54 deletions

View File

@ -0,0 +1,86 @@
import React, { useState, useRef } from 'react';
import { Flex, Box, Text } from '@chakra-ui/layout';
import { Button } from '@chakra-ui/button';
import { MdAttachFile } from 'react-icons/md';
import * as helpres from '@educt/helpers';
/**
* Hooks
*/
import { useColorModeValue } from '@chakra-ui/color-mode';
import { Input } from '@chakra-ui/input';
type FileSelectPropsType = {
onChange: (file: File) => void;
supportedFormats?: string[];
};
const FileSelect: React.FC<FileSelectPropsType> = ({ onChange, supportedFormats }) => {
const [selectedFile, setSelectedFile] = useState<File | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(null);
/**
* Handle the file input selection
*/
const onSelectFileHandler = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const onChangeFileHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const { files } = e.target;
if (files && files.length !== 0) {
const file = files[0];
onChange(file);
setSelectedFile(file);
}
};
return (
<Box
backgroundColor={useColorModeValue('gray.50', 'gray.700')}
p='15px 20px'
lineHeight='20px'
borderRadius='md'
borderWidth='1px'
>
<Input type='file' ref={fileInputRef} onChange={onChangeFileHandler} display='none' />
<Flex justifyContent='space-between' alignItems='center' sx={{ gap: '10px' }}>
<Box>
{selectedFile ? (
<Text fontSize='lg' fontWeight='bold'>
{selectedFile.name}
<br />
<Text as='small' fontWeight='normal'>
{helpres.transformBytes(selectedFile.size)}
</Text>
</Text>
) : (
<Text fontWeight='normal' color='gray'>
Choose a file...
</Text>
)}
{supportedFormats && (
<Box mt='1'>
<Text as='small' fontSize='xs' color='gray.500'>
Supported formats: {supportedFormats.join(', ')}
</Text>
</Box>
)}
</Box>
<Box p='0 5px'>
<Button onClick={onSelectFileHandler} rightIcon={<MdAttachFile />}>
Select file
</Button>
</Box>
</Flex>
</Box>
);
};
export default FileSelect;

View File

@ -0,0 +1,12 @@
import React from 'react';
import { Box, Text } from '@chakra-ui/layout';
const LoadingList: React.FC = () => {
return (
<Box textAlign='center' margin='10px 0' userSelect='none'>
<Text color='gray.500'>Wait a second...</Text>
</Box>
);
};
export default LoadingList;

View File

@ -0,0 +1,3 @@
import LoadingList from './LoadingList';
export default LoadingList;

View File

@ -29,6 +29,8 @@ const Select: React.FC<ISelectPropsType> = props => {
<MenuButton
as={Button}
variant='outline'
fontWeight='400'
fontSize='md'
width='full'
textAlign='left'
justifyContent='flex-end'

View File

@ -50,3 +50,22 @@ export function transformToFormData(data: object): FormData {
return formData;
}
/**
* Transform bytes to readable format
*
* @param bytes Number of bytes
* @param decimals
* @returns string
*/
export function transformBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}

View File

@ -54,7 +54,7 @@ const AuthForm: React.FC = () => {
};
return (
<Box w='full' maxW='450px' borderWidth='1px' borderRadius='lg' p='8'>
<Box flexBasis='500px' borderWidth='1px' borderRadius='lg' p='8'>
<form onSubmit={handleSubmit(onSubmit)}>
<Flex justifyContent='center' alignItems='center'>
<Box w='full'>
@ -67,27 +67,24 @@ const AuthForm: React.FC = () => {
<FormLabel>Login</FormLabel>
<InputGroup>
<InputLeftElement children={<Icon as={MdAccountCircle} />} />
<Input type='text' placeholder='Login' {...register('login')} isInvalid={errors.login ? true : false} />
<Input type='text' placeholder='Login' {...register('login')} isInvalid={!!errors.login} />
</InputGroup>
<Text as='small' color='red.500'>
{errors.login?.message}
</Text>
</FormControl>
<FormControl mt={5}>
<FormLabel>Password</FormLabel>
<InputGroup>
<InputLeftElement children={<Icon as={MdVpnKey} />} />
<Input
type='password'
placeholder='******'
{...register('password')}
isInvalid={errors.password ? true : false}
/>
<Input type='password' placeholder='******' {...register('password')} isInvalid={!!errors.password} />
</InputGroup>
<Text as='small' color='red.500'>
{errors.password?.message}
</Text>
</FormControl>
<Button
loadingText='Logging in...'
isLoading={loading}

View File

@ -2,7 +2,7 @@ import yup from '@educt/schema';
const LoginSchema = yup.object().shape({
login: yup.string().required('Login is required'),
password: yup.string().min(6, 'Password must be at least 6 characters').required('Password is required'),
password: yup.string().required('Password is required'),
});
export default LoginSchema;

View File

@ -7,13 +7,13 @@ import { Box, Grid, Text } from '@chakra-ui/react';
*/
import { ICourse } from '@educt/interfaces';
import { CourseStatusEnum } from '@educt/enums';
import { CourseItemPropsType } from './CourseItem';
/**
* Components
*/
import CourseItem, { CourseItemPropsType } from './CourseItem';
import CourseListLoading from './CourseListLoading';
import DeleteCourseDialog from '../DeleteCourseDialog';
import LoadingList from '@educt/components/LoadingList';
/**
* Hooks
@ -84,7 +84,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
/**
* Loading
*/
if (courses === null || isLoading) return <CourseListLoading />;
if (courses === null || isLoading) return <LoadingList />;
/**
* No courses

View File

@ -1,12 +0,0 @@
import React from 'react';
import { Box, Text } from '@chakra-ui/react';
const CourseListLoading: React.FC = () => {
return (
<Box textAlign='center' mt='10' userSelect='none'>
<Text color='gray.500'>Wait a second...</Text>
</Box>
);
};
export default CourseListLoading;

View File

@ -13,9 +13,10 @@ import {
InputRightElement,
Input,
} from '@chakra-ui/react';
import { MdCloudUpload } from 'react-icons/md';
import { MdAttachFile, MdCloudUpload } from 'react-icons/md';
import { SubmitHandler, useForm, useWatch, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import AsyncSelect from '@educt/components/AsyncSelect';
/**
* Types
@ -29,18 +30,18 @@ import { CourseStatusEnum, UserRoleEnum } from '@educt/enums';
import { useHistory } from 'react-router';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useErrorHandler } from 'react-error-boundary';
import { useColorModeValue } from '@chakra-ui/color-mode';
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
/**
* Schema
*/
import CreateCourseSchema from './CreateCourseForm.validator';
import AsyncSelect from '@educt/components/AsyncSelect';
import Select from '@educt/components/Select';
import FileSelect from '@educt/components/FileSelect/FileSelect';
type CreateCourseInputType = {
title: string;
image: FileList;
image: File;
category_id: string;
description: string;
teacher_id: string;
@ -126,7 +127,7 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
description: data.description,
teacher_id: data.teacher_id,
category_id: data.category_id,
image: data.image[0],
image: data.image,
status: status ?? CourseStatusEnum.DRAFT,
});
toast({ title: `Course successfully created.`, status: 'success' });
@ -173,7 +174,13 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
<FormControl id='image' isInvalid={!!errors.image}>
<FormLabel>Image</FormLabel>
<Input size='md' variant='unstyled' {...register('image')} type='file' accept='image/*' />
<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>

View File

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

View File

@ -52,17 +52,19 @@ const UserItem: React.FC<UserItemPropsType> = ({ user, onEdit, onDelete }) => {
return (
<Box borderBottomWidth='1px' borderRadius='lg' w='full' p='3' bg={useColorModeValue('gray.50', 'gray.700')}>
<Flex alignItems='center' justifyContent='space-between' sx={{ gap: '20px' }}>
<Flex alignItems='center' flexBasis='230px'>
<Avatar name={user.fullname} size='sm' mr='3' />
<Box>
<Flex flexDir={{ base: 'column', sm: 'row' }} alignItems='center' sx={{ gap: '20px' }}>
<Flex alignItems='center' flexDir={{ base: 'column', sm: 'row' }} flex='1 1 0'>
<Avatar name={user.fullname} size='sm' mr={{ base: '0', sm: '3' }} mb={{ base: '1', sm: '0' }} />
<Box textAlign={{ base: 'center', sm: 'left' }}>
<Link as={ReactRouterLink} to={`/user/${user.id}`} fontSize='md' fontWeight='medium'>
{user.fullname}
</Link>
<Text fontSize='sm'>{user.email}</Text>
<Text fontSize='sm' overflowWrap='anywhere'>
{user.email}
</Text>
</Box>
</Flex>
<Flex sx={{ gap: '5px' }}>
<Flex sx={{ gap: '5px' }} justifyContent='center' flex='1 1 0'>
<UserBadge roles={user.roles} />
</Flex>
<Box>

View File

@ -30,6 +30,7 @@ import { useLocation } from 'react-router';
import { useErrorHandler } from 'react-error-boundary';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useDisclosure } from '@chakra-ui/hooks';
import LoadingList from '@educt/components/LoadingList';
type UserListPropsType = { render: React.FC<UserItemPropsType> };
@ -47,7 +48,7 @@ const UserList: React.FC<UserListPropsType> = ({ render: Item }) => {
userStore.loadUsersData().catch(error => handleError(error));
}, [handleError, userStore, location.search]);
if (pagination === undefined || users === null) return <div>Loading users...</div>;
if (pagination === undefined || users === null) return <LoadingList />;
const pagesCount = Math.ceil(pagination.total / pagination.per_page);
@ -123,23 +124,21 @@ const UserList: React.FC<UserListPropsType> = ({ render: Item }) => {
<Text>Total: ({pagination.total})</Text>
<Text>Actions</Text>
</Flex>
{loading ? (
<Box textAlign='center' mt='10' userSelect='none'>
<Text color='gray.500'>Wait a second...</Text>
</Box>
) : (
{!loading ? (
<Stack mt='4' spacing='2'>
{users.map(user => (
<Item key={user.id} user={user} onEdit={onEditUser} onDelete={onDeleteUser} />
))}
</Stack>
) : (
<LoadingList />
)}
</Box>
<Flex margin='2rem 0' flexDirection={{ base: 'column', sm: 'column', md: 'row' }}>
<Box textAlign={{ base: 'center', sm: 'center', md: 'left' }} mb={{ base: '2', md: '0' }}>
Page <b>{pagination.current_page}</b> of {pagesCount}
</Box>
<Flex ml='auto' mr='auto' alignItems='center' sx={{ gap: '30px' }}>
<Flex ml='auto' mr='auto' justifyContent='center' flexWrap='wrap' alignItems='center' sx={{ gap: '30px' }}>
<Button
onClick={prevPage}
disabled={pagination.current_page <= 1}

View File

@ -43,9 +43,7 @@ const UsersPage: React.FC<IPageProps> = () => {
<Flex mt='5' flexDir='column' h='full'>
<Flex justifyContent='space-between' sx={{ gap: '10px' }} flexWrap='wrap'>
<UserSearch />
<Flex sx={{ gap: '10px' }}>
<CreateUserForm me={me} />
</Flex>
<CreateUserForm me={me} />
</Flex>
<UserList render={UserItem} />
</Flex>