feat: isMounted & login query hooks

This commit is contained in:
Sergey Yarkov 2021-08-28 23:07:06 +03:00
parent e741c2b913
commit 960bec4d5e
10 changed files with 151 additions and 101 deletions

View File

@ -1,10 +1,8 @@
import React from 'react'; import React from 'react';
import { MdAccountCircle, MdVpnKey } from 'react-icons/md'; import { MdAccountCircle, MdVpnKey } from 'react-icons/md';
import { FormControl, FormLabel, InputGroup, Input, InputLeftElement, Button, Icon, useToast } from '@chakra-ui/react'; import { FormControl, FormLabel, InputGroup, Input, InputLeftElement, Button, Icon } from '@chakra-ui/react';
import { useRootStore } from 'hooks/useRootStore';
import { observer } from 'mobx-react';
import { SubmitHandler, useForm } from 'react-hook-form'; import { SubmitHandler, useForm } from 'react-hook-form';
import { useErrorHandler } from 'react-error-boundary'; import useLoginQuery from 'hooks/useLoginQuery';
type AuthInputs = { type AuthInputs = {
login: string; login: string;
@ -13,35 +11,13 @@ type AuthInputs = {
const AuthForm: React.FC = () => { const AuthForm: React.FC = () => {
const { register, reset, handleSubmit } = useForm<AuthInputs>(); const { register, reset, handleSubmit } = useForm<AuthInputs>();
const { authStore } = useRootStore(); const { login, loading } = useLoginQuery();
const handleError = useErrorHandler();
const toast = useToast();
const onSubmit: SubmitHandler<AuthInputs> = async data => { const onSubmit: SubmitHandler<AuthInputs> = async data => {
try { try {
await authStore.login(data.login, data.password); await login(data.login, data.password);
toast({
title: `👋 Welcome back!`,
description: 'You are successfully logged in.',
isClosable: true,
status: 'success',
});
} catch (error) { } catch (error) {
if (error.response) { console.error(error);
switch (error.response.status) {
case 404:
toast({ title: 'User not found in a system.', status: 'error', duration: 2000 });
break;
case 401:
toast({ title: 'Invalid password.', status: 'error', duration: 2000 });
break;
default:
handleError(error);
break;
}
} else {
handleError(error);
}
} finally { } finally {
reset(); reset();
} }
@ -65,7 +41,7 @@ const AuthForm: React.FC = () => {
</FormControl> </FormControl>
<Button <Button
loadingText='Logging in...' loadingText='Logging in...'
isLoading={authStore.loading} isLoading={loading}
type='submit' type='submit'
colorScheme='blue' colorScheme='blue'
variant='outline' variant='outline'
@ -78,4 +54,4 @@ const AuthForm: React.FC = () => {
); );
}; };
export default observer(AuthForm); export default AuthForm;

View File

@ -3,6 +3,7 @@ import { Flex, Box } from '@chakra-ui/react';
import NavList from '../NavList'; import NavList from '../NavList';
import { useRootStore } from 'hooks/useRootStore'; import { useRootStore } from 'hooks/useRootStore';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import config from 'config';
/** /**
* *
@ -26,7 +27,7 @@ const Nav: React.FC = () => {
height='calc(100vh - 4rem)' height='calc(100vh - 4rem)'
padding='5' padding='5'
> >
<NavList /> <NavList links={config.links} />
</Flex> </Flex>
</Box> </Box>
</Box> </Box>

View File

@ -10,6 +10,7 @@ import { Drawer, DrawerOverlay, DrawerContent, DrawerBody } from '@chakra-ui/mod
import { HamburgerIcon } from '@chakra-ui/icons'; import { HamburgerIcon } from '@chakra-ui/icons';
import { useDisclosure, IconButton, useColorMode, Skeleton } from '@chakra-ui/react'; import { useDisclosure, IconButton, useColorMode, Skeleton } from '@chakra-ui/react';
import NavList from '../NavList'; import NavList from '../NavList';
import config from 'config';
const NavMobile: React.FC = () => { const NavMobile: React.FC = () => {
const { userStore } = useRootStore(); const { userStore } = useRootStore();
@ -24,7 +25,7 @@ const NavMobile: React.FC = () => {
<DrawerContent backgroundColor={colorMode === 'dark' ? 'gray.800' : 'white'}> <DrawerContent backgroundColor={colorMode === 'dark' ? 'gray.800' : 'white'}>
<DrawerBody> <DrawerBody>
<Flex as='nav' flexDirection='column' alignItems='flex-start' textDecoration='none'> <Flex as='nav' flexDirection='column' alignItems='flex-start' textDecoration='none'>
<NavList onCloseDrawer={onClose} /> <NavList links={config.links} onCloseDrawer={onClose} />
</Flex> </Flex>
{userStore.me ? ( {userStore.me ? (
<Box <Box

View File

@ -1,34 +1,27 @@
import React from 'react'; import React from 'react';
import { Box, useColorMode } from '@chakra-ui/react'; import { Box, useColorMode } from '@chakra-ui/react';
import type { LinkType } from 'interfaces'; import type { LinkType } from 'interfaces';
import { useHistory } from 'react-router-dom';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { useRootStore } from 'hooks/useRootStore'; import { useRootStore } from 'hooks/useRootStore';
export type NavLinkProps = { type NavLinkPropsType = {
link: LinkType; link: LinkType;
onCloseDrawer?: () => void; onClickLink: (link: LinkType) => void;
}; };
/** /**
* Returns the link component for navigation. * Returns the link component for navigation.
*/ */
const NavLink: React.FC<NavLinkProps> = ({ link, onCloseDrawer }) => { const NavLink: React.FC<NavLinkPropsType> = ({ link, onClickLink }) => {
const { uiStore } = useRootStore(); const { uiStore } = useRootStore();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const history = useHistory();
const handleClick = () => { const onClick = () => onClickLink(link);
history.push(link.location);
/* Close mobile drawer on click link */
onCloseDrawer && onCloseDrawer();
};
return ( return (
<Box <Box
as='a' as='a'
onClick={handleClick} onClick={onClick}
display='flex' display='flex'
margin='3px 0' margin='3px 0'
width='100%' width='100%'
@ -41,7 +34,7 @@ const NavLink: React.FC<NavLinkProps> = ({ link, onCloseDrawer }) => {
color={uiStore.location === link.location ? 'blue.400' : ''} color={uiStore.location === link.location ? 'blue.400' : ''}
backgroundColor={uiStore.location === link.location ? `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}` : ''} backgroundColor={uiStore.location === link.location ? `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}` : ''}
alignItems='center' alignItems='center'
borderRadius='9999px' borderRadius='3xl'
display='flex' display='flex'
padding='10px 15px' padding='10px 15px'
fontWeight='500' fontWeight='500'

View File

@ -1,31 +1,37 @@
import React from 'react'; import React, { useCallback } from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import config from 'config';
import NavLink from './NavLink'; import NavLink from './NavLink';
import { useRootStore } from 'hooks/useRootStore'; // import { useRootStore } from 'hooks/useRootStore';
import { userHasRoles } from 'helpers'; // import { userHasRoles } from 'helpers';
import { UserRoleEnum } from 'enums'; // import { UserRoleEnum } from 'enums';
import { useHistory } from 'react-router-dom';
import { LinkType } from 'interfaces';
type NavListPropsType = {
links: LinkType[];
onCloseDrawer?: () => void;
};
/** /**
* Render a navigation list of links * Render a navigation list of links
*/ */
const NavList: React.FC<{ onCloseDrawer?: () => void }> = ({ onCloseDrawer }) => { const NavList: React.FC<NavListPropsType> = ({ links, onCloseDrawer }) => {
const { userStore } = useRootStore(); // const { userStore } = useRootStore();
const history = useHistory();
const onClickLink = useCallback(
(link: LinkType) => {
history.push(link.location);
/* Close mobile drawer on click link */
onCloseDrawer && onCloseDrawer();
},
[history, onCloseDrawer]
);
return ( return (
<> <>
{config.links.map((link, i) => { {links.map(link => {
if (link.public) { return <NavLink link={link} key={link.location} onClickLink={onClickLink} />;
return <NavLink link={link} key={i} onCloseDrawer={onCloseDrawer} />;
} else {
/**
* Render not public link
*/
if (userStore.me && userHasRoles(userStore.me.roles, [UserRoleEnum.ADMIN])) {
return <NavLink link={link} key={i} onCloseDrawer={onCloseDrawer} />;
}
return null;
}
})} })}
</> </>
); );

View File

@ -0,0 +1,17 @@
import { useRef, useEffect } from 'react';
const useIsMountedRef = () => {
const isMountedRef = useRef(false);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return isMountedRef;
};
export default useIsMountedRef;

View File

@ -0,0 +1,68 @@
import { useToast } from '@chakra-ui/react';
import { ILoginResult } from 'interfaces';
import { useState } from 'react';
import { useErrorHandler } from 'react-error-boundary';
import useIsMountedRef from './useIsMountedRef';
import { useRootStore } from './useRootStore';
type LoginStateType = {
result: ILoginResult | null;
error: any;
loading: boolean;
fetched: boolean;
};
const useLoginQuery = () => {
const isMountedRef = useIsMountedRef();
const { authStore } = useRootStore();
const [state, setState] = useState<LoginStateType>({ result: null, error: null, loading: false, fetched: false });
const toast = useToast();
const handleError = useErrorHandler();
const login = async (login: string, password: string) => {
try {
setState(s => ({ ...s, loading: true }));
const result = await authStore.login(login, password);
toast({
title: `👋 Welcome back!`,
description: 'You are successfully logged in.',
isClosable: true,
status: 'success',
});
if (isMountedRef.current) {
setState(s => ({ ...s, result }));
}
} catch (error) {
if (isMountedRef.current) {
setState(s => ({ ...s, error }));
}
if (error.response) {
/**
* Handle response error
*/
switch (error.response.status) {
case 404:
toast({ title: 'User not found in a system.', status: 'error', duration: 2000 });
break;
case 401:
toast({ title: 'Invalid password.', status: 'error', duration: 2000 });
break;
default:
handleError(error);
break;
}
} else {
handleError(error);
}
} finally {
if (isMountedRef.current) {
setState(s => ({ ...s, loading: false, fetched: true }));
}
}
};
return { ...state, login };
};
export default useLoginQuery;

View File

@ -1,11 +1,12 @@
import React from 'react'; import { useState } from 'react';
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useRootStore } from './useRootStore'; import { useRootStore } from './useRootStore';
import { IDataResult } from 'interfaces'; import { IDataResult } from 'interfaces';
import { useErrorHandler } from 'react-error-boundary'; import { useErrorHandler } from 'react-error-boundary';
import useIsMountedRef from './useIsMountedRef';
type StateType = { type LogoutStateType = {
result: IDataResult | null; result: IDataResult | null;
error: any; error: any;
loading: boolean; loading: boolean;
@ -14,34 +15,33 @@ type StateType = {
const useLogoutQuery = () => { const useLogoutQuery = () => {
const { authStore } = useRootStore(); const { authStore } = useRootStore();
const [state, setState] = React.useState<StateType>({ result: null, error: null, loading: false, fetched: false }); const [state, setState] = useState<LogoutStateType>({ result: null, error: null, loading: false, fetched: false });
const isMounted = React.useRef(true); const isMountedRef = useIsMountedRef();
const history = useHistory(); const history = useHistory();
const toast = useToast(); const toast = useToast();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const request = React.useCallback(async () => {
const logout = async () => {
setState(s => ({ ...s, loading: true })); setState(s => ({ ...s, loading: true }));
try { try {
const data = await authStore.logout(); const data = await authStore.logout();
history.push('/auth'); history.push('/auth');
toast({ title: 'You are logged out.', isClosable: true, status: 'info' }); toast({ title: 'You are logged out.', isClosable: true, status: 'info' });
if (isMounted.current) setState(s => ({ ...s, result: data })); if (isMountedRef.current) {
} catch (error) { setState(s => ({ ...s, result: data }));
handleError(error); }
setState(s => ({ ...s, error })); } catch (error) {
} finally { if (isMountedRef.current) {
if (isMounted.current) setState(s => ({ ...s, loading: false, fetched: true })); setState(s => ({ ...s, error }));
}
handleError(error);
} finally {
if (isMountedRef.current) {
setState(s => ({ ...s, loading: false, fetched: true }));
}
} }
}, [authStore, history, toast, handleError]);
const logout = () => request();
React.useEffect(() => {
return () => {
isMounted.current = false;
}; };
}, []);
return { ...state, logout }; return { ...state, logout };
}; };

View File

@ -1,13 +1,13 @@
import { observer } from 'mobx-react';
import Helmet from 'react-helmet';
import React from 'react'; import React from 'react';
import { Flex, Box } from '@chakra-ui/react'; import Helmet from 'react-helmet';
import { MdSchool } from 'react-icons/md'; import { Redirect } from 'react-router-dom';
import { observer } from 'mobx-react';
import { IPageProps } from 'interfaces'; import { IPageProps } from 'interfaces';
import { Flex, Box } from '@chakra-ui/react';
import { useRootStore } from 'hooks/useRootStore';
import { MdSchool } from 'react-icons/md';
import AuthForm from 'components/Auth/AuthForm'; import AuthForm from 'components/Auth/AuthForm';
import { useRootStore } from 'hooks/useRootStore';
import { Redirect } from 'react-router-dom';
/** /**
* Auth page * Auth page

View File

@ -1,6 +1,6 @@
import { makeAutoObservable } from 'mobx'; import { makeAutoObservable } from 'mobx';
import Cookies from 'js-cookie';
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import Cookies from 'js-cookie';
import { IDataResult, ILoginResult } from 'interfaces'; import { IDataResult, ILoginResult } from 'interfaces';
/** /**
@ -20,8 +20,6 @@ export default class AuthStore {
public isLoggedIn: boolean = !!Cookies.get('logged_in'); public isLoggedIn: boolean = !!Cookies.get('logged_in');
public loading: boolean = false;
constructor(root: RootStore, api: AxiosInstance) { constructor(root: RootStore, api: AxiosInstance) {
this.root = root; this.root = root;
this.authService = new AuthService(api); this.authService = new AuthService(api);
@ -32,13 +30,8 @@ export default class AuthStore {
this.isLoggedIn = value; this.isLoggedIn = value;
} }
public setLoading(value: boolean) {
this.loading = value;
}
public async login(login: string, password: string): Promise<ILoginResult> { public async login(login: string, password: string): Promise<ILoginResult> {
try { try {
this.setLoading(true);
const result = await this.authService.requestLogin(login, password); const result = await this.authService.requestLogin(login, password);
this.setIsLoggedIn(true); this.setIsLoggedIn(true);
@ -47,14 +40,11 @@ export default class AuthStore {
return result; return result;
} catch (error) { } catch (error) {
throw error; throw error;
} finally {
this.setLoading(false);
} }
} }
public async logout(): Promise<IDataResult> { public async logout(): Promise<IDataResult> {
try { try {
this.setLoading(true);
const result = await this.authService.requestLogout(); const result = await this.authService.requestLogout();
this.setIsLoggedIn(false); this.setIsLoggedIn(false);
@ -63,8 +53,6 @@ export default class AuthStore {
return result; return result;
} catch (error) { } catch (error) {
throw error; throw error;
} finally {
this.setLoading(false);
} }
} }
} }