feat: refactor & added custom async select

This commit is contained in:
Sergey Yarkov 2021-11-03 21:14:12 +03:00
parent 3e69a47907
commit eb33113912
13 changed files with 151 additions and 129 deletions

72
package-lock.json generated
View File

@ -953,18 +953,6 @@
}
}
},
"@emotion/cache": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.4.0.tgz",
"integrity": "sha512-Zx70bjE7LErRO9OaZrhf22Qye1y4F7iDl+ITjet0J+i+B88PrAOBkKvaAWhxsZf72tDLajwCgfCjJ2dvH77C3g==",
"requires": {
"@emotion/memoize": "^0.7.4",
"@emotion/sheet": "^1.0.0",
"@emotion/utils": "^1.0.0",
"@emotion/weak-memoize": "^0.2.5",
"stylis": "^4.0.3"
}
},
"@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
@ -1028,11 +1016,6 @@
"csstype": "^3.0.2"
}
},
"@emotion/sheet": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.0.2.tgz",
"integrity": "sha512-QQPB1B70JEVUHuNtzjHftMGv6eC3Y9wqavyarj4x4lg47RACkeSfNo5pxIOKizwS9AEFLohsqoaxGQj4p0vSIw=="
},
"@emotion/styled": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.3.0.tgz",
@ -1206,14 +1189,6 @@
"@types/react-router": "*"
}
},
"@types/react-transition-group": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz",
"integrity": "sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==",
"requires": {
"@types/react": "*"
}
},
"@types/scheduler": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@ -1414,15 +1389,6 @@
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
"dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"requires": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"electron-to-chromium": {
"version": "1.3.870",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.870.tgz",
@ -1870,11 +1836,6 @@
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"mini-create-react-context": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz",
@ -2207,25 +2168,19 @@
"tiny-warning": "^1.0.0"
}
},
"react-select": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.1.0.tgz",
"integrity": "sha512-SkEBD1AYsSXrIdNj5HBt7+Ehe+jxdiB448J0atJqR6lE3l/GcFlRf4JYB3NlHe/02jrW4AnIQLo1t0IqWrxXOw==",
"requires": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.1.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^5.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0"
}
},
"react-side-effect": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.1.tgz",
"integrity": "sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ=="
},
"react-spinners": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.11.0.tgz",
"integrity": "sha512-rDZc0ABWn/M1OryboGsWVmIPg8uYWl0L35jPUhr40+Yg+syVPjeHwvnB7XWaRpaKus3M0cG9BiJA+ZB0dAwWyw==",
"requires": {
"@emotion/react": "^11.1.4"
}
},
"react-style-singleton": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.1.1.tgz",
@ -2243,17 +2198,6 @@
}
}
},
"react-transition-group": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
"integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
"requires": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
}
},
"regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",

View File

@ -27,7 +27,7 @@
"react-hook-form": "^7.12.2",
"react-icons": "^3.11.0",
"react-router-dom": "^5.2.0",
"react-select": "^5.1.0",
"react-spinners": "^0.11.0",
"validator": "^13.6.0",
"yup": "^0.32.9"
},

View File

@ -0,0 +1,25 @@
import React, { useEffect, useState } from 'react';
import Select from '@educt/components/Select';
import { ISelectPropsType } from '../Select/Select';
import { OptionType } from '@educt/types';
interface IAsyncSelectPropsType extends ISelectPropsType {
loadOptions: () => Promise<OptionType[] | undefined>;
}
const AsyncSelect: React.FC<IAsyncSelectPropsType> = props => {
const [options, setOptions] = useState<OptionType[] | []>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { loadOptions, ...selectProps } = props;
useEffect(() => {
setIsLoading(true);
loadOptions()
.then(options => options && setOptions(options))
.finally(() => setIsLoading(false));
}, []);
return <Select options={options} isLoading={isLoading} {...selectProps} />;
};
export default AsyncSelect;

View File

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

View File

@ -46,7 +46,7 @@ const NavMobile: React.FC = () => {
padding='10px'
borderRadius='md'
marginTop='auto'
backgroundColor={colorMode === 'dark' ? 'gray.700' : 'gray.100'}
backgroundColor={colorMode === 'dark' ? 'gray.700' : 'gray.50'}
>
<Flex alignItems='center'>
<Avatar size='sm' name={me.fullname} marginRight={3} />

View File

@ -0,0 +1,63 @@
import React from 'react';
import { BeatLoader } from 'react-spinners';
import { Box } from '@chakra-ui/layout';
import { Button } from '@chakra-ui/button';
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { ChevronDownIcon } from '@chakra-ui/icons';
/**
* Types
*/
import type { OptionType } from '@educt/types';
export interface ISelectPropsType {
options?: OptionType[];
value?: string;
onChange: (newValue: string) => void;
placeholder?: string;
noOptionsMessage?: string;
isLoading?: boolean;
loadingText?: string;
}
const Select: React.FC<ISelectPropsType> = props => {
const { options, value, noOptionsMessage, loadingText, isLoading } = props;
return (
<>
<Menu isLazy matchWidth>
<MenuButton
as={Button}
variant='outline'
width='full'
textAlign='left'
justifyContent='flex-end'
isLoading={isLoading}
loadingText={loadingText || 'Loading...'}
spinner={<BeatLoader size={6} color='gray' />}
rightIcon={<ChevronDownIcon />}
>
{options?.find(option => option.value === value)?.label || props?.placeholder || 'Select Option'}
</MenuButton>
<MenuList>
{options && options.length !== 0 ? (
options.map(option => (
<MenuItem
key={option.value}
onClick={() => {
props.onChange(option.value);
}}
>
{option.label}
</MenuItem>
))
) : (
<Box textAlign='center'>{noOptionsMessage || 'No options.'}</Box>
)}
</MenuList>
</Menu>
</>
);
};
export default Select;

View File

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

View File

@ -1,13 +1,24 @@
import React from 'react';
import Helmet from 'react-helmet';
import { Redirect } from 'react-router-dom';
import { observer } from 'mobx-react';
import { IPageProps } from '@educt/interfaces';
import { Redirect } from 'react-router-dom';
import { Flex } from '@chakra-ui/react';
import { useRootStore } from '@educt/hooks/useRootStore';
/**
* Types
*/
import { IPageProps } from '@educt/interfaces';
/**
* Components
*/
import AuthForm from './components/AuthForm';
/**
* Hooks
*/
import { useRootStore } from '@educt/hooks/useRootStore';
/**
* Auth page
*/
@ -16,6 +27,7 @@ const AuthPage: React.FC<IPageProps> = ({ title }) => {
/**
* User already authorized
* This is triggered by mobx observer function
*/
if (authStore.isLoggedIn) {
return <Redirect to='/' />;

View File

@ -16,8 +16,6 @@ import {
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
@ -37,6 +35,8 @@ import useIsMountedRef from '@educt/hooks/useIsMountedRef';
* Schema
*/
import CreateCourseSchema from './CreateCourseForm.validator';
import AsyncSelect from '@educt/components/AsyncSelect';
import Select from '@educt/components/Select';
type CreateCourseInputType = {
title: string;
@ -79,17 +79,14 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
/**
* Load users into select field
*/
const loadUsersOptions = async (inputValue: string): Promise<OptionType[] | undefined> => {
const loadUsersOptions = async (): Promise<OptionType[] | undefined> => {
try {
const users = await userService.fetchAll({ search: inputValue, role: UserRoleEnum.TEACHER });
const users = await userService.fetchAll({ limit: 12, role: UserRoleEnum.TEACHER });
// TODO: remove filter later
return users.data
.filter(user => helpers.userContainRoles(user.roles, [UserRoleEnum.TEACHER]))
.map(user => ({
label: user.fullname,
value: user.id,
}));
return users.data.map(user => ({
label: user.fullname,
value: user.id,
}));
} catch (error: any) {
if (error.response) {
toast({ title: 'Error' });
@ -102,7 +99,7 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
/**
* Load categories into select filed
*/
const loadCategoriesOptions = async (inputValue: string): Promise<OptionType[] | undefined> => {
const loadCategoriesOptions = async (): Promise<OptionType[] | undefined> => {
try {
const categories = await categoryService.fetchAll();
return categories.data.map(category => ({
@ -163,8 +160,8 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
<InputRightElement
mr='2'
children={
<Text as='small' color={!!errors.title || title.length > 60 ? 'red.500' : 'gray.500'}>
{title.length}/60
<Text as='small' color={!!errors.title || title.length > 90 ? 'red.500' : 'gray.500'}>
{title.length}/90
</Text>
}
/>
@ -187,16 +184,14 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
<Controller
control={control}
name='category_id'
render={({ field: { onChange } }) => (
render={({ field: { onChange, value } }) => (
<AsyncSelect
defaultOptions
cacheOptions
loadOptions={loadCategoriesOptions}
isSearchable={false}
onChange={selected => onChange(selected?.value)}
onChange={selected => onChange(selected)}
value={value}
placeholder='Select Category'
loadingMessage={() => 'Loading...'}
noOptionsMessage={() => 'There are no categories.'}
noOptionsMessage='There are no categories.'
loadingText='Wait...'
/>
)}
/>
@ -209,15 +204,14 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
<Controller
control={control}
name='teacher_id'
render={({ field: { onChange } }) => (
render={({ field: { onChange, value } }) => (
<AsyncSelect
defaultOptions
cacheOptions
loadOptions={loadUsersOptions}
onChange={selected => onChange(selected?.value)}
onChange={selected => onChange(selected)}
value={value}
placeholder='Select Teacher'
loadingMessage={() => 'Loading...'}
noOptionsMessage={() => 'Cannot find any teacher.'}
noOptionsMessage='There are no teachers.'
loadingText='Wait...'
/>
)}
/>

View File

@ -4,7 +4,7 @@ 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').max(60, 'Title must be at most 60 characters'),
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()

View File

@ -7,22 +7,12 @@ import { observer } from 'mobx-react';
/**
* Main page
*/
const MainPage: React.FC<IPageProps> = ({ title }) => {
const MainPage: React.FC<IPageProps> = () => {
return (
<>
<Breadcrumb fontWeight='medium' fontSize='sm'>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href='/'>{title}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Box mt='10'>
<Box>
<Heading as='h1'>Main page</Heading>
<Grid templateColumns='3fr 1fr' mt='25px'></Grid>
</Box>
</Box>
</>
<Box>
<Heading as='h1'>Main page</Heading>
</Box>
);
};
export default observer(MainPage);
export default MainPage;

View File

@ -6,23 +6,11 @@ import { IPageProps } from '@educt/interfaces';
/**
* Messages page
*/
const MessagesPage: React.FC<IPageProps> = ({ title }) => {
const MessagesPage: React.FC<IPageProps> = () => {
return (
<>
<Breadcrumb fontWeight='medium' fontSize='sm'>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href='/'>{title}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Box mt='10'>
<Box>
<Heading as='h1'>Messages page</Heading>
<Grid templateColumns='3fr 1fr' mt='25px'>
...
</Grid>
</Box>
</Box>
</>
<Box>
<Heading as='h1'>Messages page</Heading>
</Box>
);
};

View File

@ -147,4 +147,4 @@ const ProfilePage: React.FC<IPageProps> = () => {
);
};
export default observer(ProfilePage);
export default ProfilePage;