feat: ui store & updated nav

This commit is contained in:
Sergey Yarkov 2021-08-25 17:31:10 +03:00
parent e1fd995d53
commit f48c1d2a2c
22 changed files with 203 additions and 55 deletions

View File

@ -1,5 +1,5 @@
import React from 'react';
import { BrowserRouter as Router, Switch } from 'react-router-dom';
import { Router, Switch } from 'react-router-dom';
/**
* Components
@ -14,17 +14,22 @@ import MainPage from 'pages/main';
import CoursesPage from 'pages/courses';
import MessagesPage from 'pages/messages';
import ProfilePage from 'pages/profile';
import ControlPage from 'pages/control';
import AuthPage from 'pages/auth';
import NotFoundPage from 'pages/404';
import { UserRoleEnum } from 'enums';
import { useRootStore } from 'hooks/useRootStore';
const App = () => {
const { uiStore } = useRootStore();
return (
<Router>
<Router history={uiStore.history}>
<Switch>
<PrivateRoute exact path='/' component={MainPage} title='Main' />
<PrivateRoute exact path='/' component={MainPage} title='Home' />
<PrivateRoute path='/courses' component={CoursesPage} title='Courses' />
<PrivateRoute path='/messages' component={MessagesPage} title='Messages' />
<PrivateRoute path='/profile' component={ProfilePage} title='Profile' />
<PrivateRoute path='/control' component={ControlPage} title='Control' roles={[UserRoleEnum.ADMIN]} />
<PublicRoute path='/auth' component={AuthPage} />
<PublicRoute component={NotFoundPage} />
</Switch>

View File

@ -2,7 +2,6 @@ import React from 'react';
import { MdAccountCircle, MdVpnKey } from 'react-icons/md';
import { FormControl, FormLabel, InputGroup, Input, InputLeftElement, Button, Icon, useToast } from '@chakra-ui/react';
import { useRootStore } from 'hooks/useRootStore';
import { useHistory } from 'react-router-dom';
import { observer } from 'mobx-react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useErrorHandler } from 'react-error-boundary';
@ -15,14 +14,12 @@ type AuthInputs = {
const AuthForm: React.FC = () => {
const { register, reset, handleSubmit } = useForm<AuthInputs>();
const { authStore } = useRootStore();
const history = useHistory();
const handleError = useErrorHandler();
const toast = useToast();
const onSubmit: SubmitHandler<AuthInputs> = async data => {
try {
await authStore.login(data.login, data.password);
history.push('/');
toast({
title: `👋 Welcome back!`,
description: 'You are successfully logged in.',

View File

@ -5,6 +5,7 @@ import UserInfo from './User/UserInfo';
import UserNotifications from './User/UserNotifications';
import useWindowDimensions from 'hooks/useWindowDimensions';
import NavMobile from '../Nav/Mobile/Nav';
import { observer } from 'mobx-react';
const Header: React.FC = () => {
const { isDesktop } = useWindowDimensions();
@ -45,4 +46,4 @@ const Header: React.FC = () => {
);
};
export default Header;
export default observer(Header);

View File

@ -18,7 +18,7 @@ import { MdSettings, MdExitToApp } from 'react-icons/md';
import { observer } from 'mobx-react';
import { useRootStore } from 'hooks/useRootStore';
import { useHistory, Link } from 'react-router-dom';
import UserBadge from 'components/Header/User/UserBadge';
import UserBadge from 'components/User/UserBadge';
const UserInfo: React.FC = () => {
const { authStore, userStore } = useRootStore();
@ -36,7 +36,7 @@ const UserInfo: React.FC = () => {
};
if (userStore.me === null) {
return <Skeleton width='260px' borderRadius='md' />;
return <Skeleton width='260px' height='40px' borderRadius='md' />;
}
return (

View File

@ -1,9 +1,18 @@
import React from 'react';
import { Box } from '@chakra-ui/layout';
import { Skeleton } from '@chakra-ui/skeleton';
import { Button } from '@chakra-ui/button';
import { MdNotifications } from 'react-icons/md';
import { useRootStore } from 'hooks/useRootStore';
import { observer } from 'mobx-react';
const UserNotifications: React.FC = () => {
const { userStore } = useRootStore();
if (userStore.me === null) {
return <Skeleton width='50px' height='40px' mr={3} ml={3} borderRadius='md' />;
}
return (
<Button
mr={3}
@ -30,4 +39,4 @@ const UserNotifications: React.FC = () => {
);
};
export default UserNotifications;
export default observer(UserNotifications);

View File

@ -6,6 +6,7 @@ import { observer } from 'mobx-react';
import { useRootStore } from 'hooks/useRootStore';
import { useErrorHandler } from 'react-error-boundary';
import { ColorModeSwitcher } from 'components/ColorModeSwitcher/ColorModeSwitcher';
import LoadingPage from 'components/Loading/LoadingPage';
/**
*
@ -37,7 +38,7 @@ const Layout: React.FC = ({ children }) => {
}}
>
<Box as='main' mr='auto' ml='auto' maxW='85rem' pl={5} pr={5} pt='2rem' pb='2rem'>
{children}
{userStore.me === null ? <LoadingPage /> : children}
<ColorModeSwitcher position='absolute' right={0} bottom={0} margin={5} />
</Box>
</Box>

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Skeleton } from '@chakra-ui/skeleton';
import { Box } from '@chakra-ui/layout';
const LoadingPage: React.FC = () => {
return (
<Box>
<Skeleton height='20px' width='200px' mb='10' borderRadius='md' />
<Skeleton height='43px' width='400px' borderRadius='md' />
</Box>
);
};
export default LoadingPage;

View File

@ -1,8 +1,7 @@
import React from 'react';
import NavLink from './NavLink';
import config from 'config';
import { Flex, Box } from '@chakra-ui/react';
import useWindowDimensions from 'hooks/useWindowDimensions';
import NavList from '../NavList';
/**
*
@ -24,9 +23,7 @@ const Nav: React.FC = () => {
height='calc(100vh - 4rem)'
padding='5'
>
{config.links.map((link, i) => (
<NavLink link={link} key={i} />
))}
<NavList />
</Flex>
</Box>
</Box>

View File

@ -1,6 +1,5 @@
import React from 'react';
import { observer } from 'mobx-react';
import config from 'config';
import { Link } from 'react-router-dom';
import { useRootStore } from 'hooks/useRootStore';
@ -9,7 +8,7 @@ import { Flex, Box, Text } from '@chakra-ui/layout';
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody } from '@chakra-ui/modal';
import { HamburgerIcon } from '@chakra-ui/icons';
import { useDisclosure, IconButton, useColorMode, Skeleton } from '@chakra-ui/react';
import NavLink from '../Desktop/NavLink';
import NavList from '../NavList';
const NavMobile: React.FC = () => {
const { userStore } = useRootStore();
@ -24,9 +23,7 @@ const NavMobile: React.FC = () => {
<DrawerContent backgroundColor={colorMode === 'dark' ? 'gray.800' : 'white'}>
<DrawerBody>
<Flex as='nav' flexDirection='column' alignItems='flex-start' textDecoration='none'>
{config.links.map((link, i) => (
<NavLink link={link} key={i} onCloseDrawer={onClose} />
))}
<NavList onCloseDrawer={onClose} />
</Flex>
{userStore.me ? (
<Box

View File

@ -2,18 +2,19 @@ import React from 'react';
import { Box, useColorMode } from '@chakra-ui/react';
import type { LinkType } from 'interfaces';
import { useHistory } from 'react-router-dom';
import { observer } from 'mobx-react';
import { useRootStore } from 'hooks/useRootStore';
type ActiveLinkState = {
title: string | null;
export type NavLinkProps = {
link: LinkType;
onCloseDrawer?: () => void;
};
/**
*
* NavLink component
* Returns the link component for navigation.
*/
const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ link, onCloseDrawer }) => {
const [activeLink, setActiveLink] = React.useState<ActiveLinkState>({ title: null });
const NavLink: React.FC<NavLinkProps> = ({ link, onCloseDrawer }) => {
const { uiStore } = useRootStore();
const { colorMode } = useColorMode();
const history = useHistory();
@ -27,8 +28,6 @@ const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ lin
return (
<Box
as='a'
onMouseEnter={() => setActiveLink({ title: link.title })}
onMouseLeave={() => setActiveLink({ title: null })}
onClick={handleClick}
display='flex'
margin='3px 0'
@ -39,12 +38,8 @@ const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ lin
cursor='pointer'
>
<Box
color={history.location.pathname === link.location || activeLink.title === link.title ? 'blue.400' : ''}
backgroundColor={
activeLink.title === link.title || history.location.pathname === link.location
? `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}`
: ''
}
color={uiStore.location === link.location ? 'blue.400' : ''}
backgroundColor={uiStore.location === link.location ? `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}` : ''}
alignItems='center'
borderRadius='9999px'
display='flex'
@ -59,4 +54,4 @@ const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ lin
);
};
export default NavLink;
export default observer(NavLink);

View File

@ -0,0 +1,34 @@
import React from 'react';
import { observer } from 'mobx-react';
import config from 'config';
import NavLink from './NavLink';
import { useRootStore } from 'hooks/useRootStore';
import { userHasRoles } from 'helpers';
import { UserRoleEnum } from 'enums';
/**
* Render a navigation list of links
*/
const NavList: React.FC<{ onCloseDrawer?: () => void }> = ({ onCloseDrawer }) => {
const { userStore } = useRootStore();
return (
<>
{config.links.map((link, i) => {
if (link.public) {
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;
}
})}
</>
);
};
export default observer(NavList);

View File

@ -1,18 +1,19 @@
import React from 'react';
import Helmet from 'react-helmet';
import Layout from 'components/Layout/Layout';
import Layout from '../Layout/Layout';
import { Route, Redirect } from 'react-router-dom';
import { IPrivateRouteProps } from 'interfaces';
import { useRootStore } from 'hooks/useRootStore';
import { observer } from 'mobx-react';
import { ErrorBoundary } from 'react-error-boundary';
import ErrorFallback from '../ErrorFallback';
import config from 'config';
/**
* Checks if the user is loggedIn by reactive variable and if not,
* then a redirect to "/auth" route.
*/
const PrivateRoute: React.FC<IPrivateRouteProps> = ({ component: Component, title, ...options }) => {
const PrivateRoute: React.FC<IPrivateRouteProps> = ({ component: Component, title, roles, ...options }) => {
const { authStore } = useRootStore();
return (
@ -23,9 +24,11 @@ const PrivateRoute: React.FC<IPrivateRouteProps> = ({ component: Component, titl
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Layout>
<Helmet>
<title>{title} • App </title>
<title>
{title} • {config.metaData.appName}{' '}
</title>
</Helmet>
<Component {...props} title={title} />
<Component {...props} title={title} roles={roles} />
</Layout>
</ErrorBoundary>
) : (

View File

@ -1,4 +1,4 @@
import { MdAccountCircle, MdCollectionsBookmark, MdHome, MdMessage } from 'react-icons/md';
import { MdAccountCircle, MdBuild, MdCollectionsBookmark, MdHome, MdMessage } from 'react-icons/md';
import { IAppConfig } from './interfaces';
const config: IAppConfig = {
@ -7,10 +7,11 @@ const config: IAppConfig = {
appDescription: 'Learn management system',
},
links: [
{ location: '/', title: 'Home', icon: MdHome },
{ location: '/courses', title: 'Courses', icon: MdCollectionsBookmark },
{ location: '/messages', title: 'Messages', icon: MdMessage },
{ location: '/profile', title: 'Profile', icon: MdAccountCircle },
{ location: '/', title: 'Home', public: true, icon: MdHome },
{ location: '/courses', title: 'Courses', public: true, icon: MdCollectionsBookmark },
{ location: '/messages', title: 'Messages', public: true, icon: MdMessage },
{ location: '/profile', title: 'Profile', public: true, icon: MdAccountCircle },
{ location: '/control', title: 'Control', public: false, icon: MdBuild },
],
};

14
src/helpers/index.ts Normal file
View File

@ -0,0 +1,14 @@
import { UserRoleEnum } from 'enums';
import { IUserRole } from 'interfaces';
export function userHasRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boolean {
for (let i = 0; i < roles.length; i += 1) {
if (!userRoles.map(r => r.slug).includes(roles[i])) {
/**
* Unable to find current role in user roles
*/
return false;
}
}
return true;
}

View File

@ -6,6 +6,7 @@ import { UserRoleEnum } from '../enums';
export type LinkType = {
location: string;
title: string;
public: boolean;
icon: IconType;
};
@ -17,6 +18,14 @@ export interface IAppConfig {
links: LinkType[];
}
export interface IUser {
id: string;
first_name: string;
last_name: string;
email: string;
roles: IUserRole[];
}
export interface IUserRole {
id: number;
name: string;
@ -49,11 +58,13 @@ export interface IUserResult extends IDataResult {
export interface IPageProps extends RouteComponentProps {
title?: string;
roles?: UserRoleEnum[];
}
export interface IPrivateRouteProps extends RouteProps {
component: React.FC<IPageProps>;
title?: string;
roles?: UserRoleEnum[];
}
export interface IPublicRouteProps extends RouteProps {

39
src/pages/control.tsx Normal file
View File

@ -0,0 +1,39 @@
import React from 'react';
import { Box, Heading, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Grid } from '@chakra-ui/react';
import { IPageProps } from 'interfaces';
import { observer } from 'mobx-react';
import { useRootStore } from 'hooks/useRootStore';
import { userHasRoles } from 'helpers';
import { Redirect } from 'react-router-dom';
/**
* Control page
*/
const ControlPage: React.FC<IPageProps> = ({ title, roles }) => {
const { userStore } = useRootStore();
if (userStore.me && roles && !userHasRoles(userStore.me.roles, roles)) {
return <Redirect to='/404' />;
}
return (
<>
<Breadcrumb fontWeight='medium' fontSize='sm'>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href='/'>{title}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Box mt='10'>
<Box>
<Heading as='h1'>Control page</Heading>
<Grid templateColumns='3fr 1fr' mt='25px'>
...
</Grid>
</Box>
</Box>
</>
);
};
export default observer(ControlPage);

View File

@ -12,7 +12,7 @@ const MainPage: React.FC<IPageProps> = ({ title }) => {
<>
<Breadcrumb fontWeight='medium' fontSize='sm'>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href='/'>Main page</BreadcrumbLink>
<BreadcrumbLink href='/'>{title}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Box mt='10'>

View File

@ -50,6 +50,7 @@ export default class AuthStore {
const result = await this.authService.requestLogout();
this.setIsLoggedIn(false);
this.root.userStore.reset();
Cookies.remove('logged_in');
return result;
} catch (error) {

View File

@ -11,8 +11,11 @@ import ApiService from 'services/ApiService';
*/
import AuthStore from './AuthStore';
import UserStore from './UserStore';
import UIStore from './UIStore';
export default class RootStore {
public uiStore: UIStore;
public authStore: AuthStore;
public userStore: UserStore;
@ -32,6 +35,7 @@ export default class RootStore {
/**
* Stores
*/
this.uiStore = new UIStore(this);
this.authStore = new AuthStore(this, this.apiService.api);
this.userStore = new UserStore(this, this.apiService.api);

27
src/stores/UIStore.ts Normal file
View File

@ -0,0 +1,27 @@
import { makeAutoObservable } from 'mobx';
import { createBrowserHistory } from 'history';
import RootStore from './RootStore';
export default class UIStore {
public root: RootStore;
public location: string = document.location.pathname;
public history: ReturnType<typeof createBrowserHistory>;
constructor(root: RootStore) {
this.root = root;
this.history = createBrowserHistory();
makeAutoObservable(this);
this.history.listen(({ pathname }) => {
console.log('path chaged');
this.location = pathname;
});
}
public setLocation(location: string) {
this.location = location;
}
}

View File

@ -1,18 +1,10 @@
import { AxiosInstance } from 'axios';
import { makeAutoObservable, runInAction } from 'mobx';
import { IUserResult, IUserRole } from 'interfaces';
import { IUserResult, IUser } from 'interfaces';
import UserService from 'services/UserService';
import RootStore from './RootStore';
export interface IUser {
id: string;
first_name: string;
last_name: string;
email: string;
roles: IUserRole[];
}
export default class UserStore {
public root: RootStore;
@ -41,4 +33,10 @@ export default class UserStore {
throw error;
}
}
public reset(): void {
runInAction(() => {
this.me = null;
});
}
}