#20 add drawer menu

Also:
* Extract User Menu into own Component
This commit is contained in:
Christoph Lienhard 2021-02-07 22:18:05 +01:00
parent 741874d07d
commit 9944f8a38b
Signed by: christoph.lienhard
GPG Key ID: 6B98870DDC270884
13 changed files with 329 additions and 147 deletions

View File

@ -4,8 +4,7 @@ import Main from "./components/Main";
import { Redirect, Route, RouteProps, Switch } from "react-router-dom";
import SignIn from "./components/SignIn";
import SignUp from "./components/SignUp";
export const isLoggedIn = (): boolean => !!localStorage.getItem("token");
import { getJsonWebToken, isLoggedIn } from "./jwt/jwt";
function PrivateRoute({ children, ...rest }: RouteProps) {
return (
@ -47,10 +46,12 @@ function NotLoggedInOnlyRoute({ children, ...rest }: RouteProps) {
}
function App(): React.ReactElement {
const jwt = getJsonWebToken();
return (
<Switch>
<PrivateRoute exact path={"/"}>
<Main />
{jwt && <Main userRole={jwt.role} userRowId={jwt.person_row_id} />}
</PrivateRoute>
<NotLoggedInOnlyRoute path={"/login"}>
<SignIn />

View File

@ -1,82 +1,71 @@
import React from "react";
import AppBar from "@material-ui/core/AppBar";
import { IconButton, MenuItem, Toolbar, Typography } from "@material-ui/core";
import {
createStyles,
IconButton,
Toolbar,
Typography,
} from "@material-ui/core";
import { makeStyles, Theme } from "@material-ui/core/styles";
import { ProfileMenu } from "./ProfileMenu";
import { mainMenuOpen, mainMenuWidth } from "./MainMenu";
import MenuIcon from "@material-ui/icons/Menu";
import Menu from "@material-ui/core/Menu";
import AccountCircle from "@material-ui/icons/AccountCircle";
import { makeStyles } from "@material-ui/core/styles";
import { useHistory } from "react-router-dom";
import { client } from "../backend/helper";
import { useReactiveVar } from "@apollo/client";
import clsx from "clsx";
const useStyles = makeStyles({
menuButton: {
marginRight: 16,
},
title: {
flexGrow: 1,
},
});
const useStyles = makeStyles((theme: Theme) =>
createStyles({
appBar: {
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
width: `calc(100% - ${mainMenuWidth}px)`,
marginLeft: mainMenuWidth,
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
},
hide: {
display: "none",
},
menuButton: {
marginRight: theme.spacing(2),
},
title: {
flexGrow: 1,
},
})
);
function CustomAppBar(): React.ReactElement {
const classes = useStyles();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const history = useHistory();
const open = useReactiveVar(mainMenuOpen);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleLogout = async () => {
await client.resetStore();
localStorage.removeItem("token");
history.push("/login");
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<AppBar>
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
className={clsx(classes.menuButton, open && classes.hide)}
color="inherit"
aria-label="menu"
onClick={() => mainMenuOpen(true)}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
Candymat
</Typography>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
<ProfileMenu />
</Toolbar>
</AppBar>
);

View File

@ -11,14 +11,15 @@ const useStyles = makeStyles((theme) => ({
},
}));
interface MainPageCandidateProps {
interface HomePageCandidateProps {
personRowId: number;
}
export function MainPageCandidate(
props: MainPageCandidateProps
export function HomePageCandidate(
props: HomePageCandidateProps
): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<QuestionAnswersList personRowId={props.personRowId} />

View File

@ -13,7 +13,7 @@ const useStyles = makeStyles((theme) => ({
},
}));
export function MainPageEditor(): React.ReactElement {
export function HomePageEditor(): React.ReactElement {
const classes = useStyles();
return (

View File

@ -10,7 +10,7 @@ const useStyles = makeStyles((theme) => ({
},
}));
export function MainPageUser(): React.ReactElement {
export function HomePageUser(): React.ReactElement {
const classes = useStyles();
return (

View File

@ -4,38 +4,52 @@ import { MockedProvider } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import Main from "./Main";
import { SnackbarProvider } from "notistack";
import { JwtPayload } from "../jwt/jwt";
function renderMainPage() {
function renderMainPage(jwt: JwtPayload) {
render(
<MockedProvider>
<MemoryRouter>
<SnackbarProvider>
<Main />
<Main userRole={jwt.role} userRowId={jwt.person_row_id} />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const baseJwt: JwtPayload = {
aud: "postgraphile",
exp: 0,
iat: 0,
iss: "postgraphile",
role: "candymat_person",
person_row_id: 3,
};
describe("The main page", () => {
test("displays the editors page if an editor is logged in", () => {
const editorToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c";
localStorage.setItem("token", editorToken);
renderMainPage();
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_editor",
person_row_id: 1,
};
renderMainPage(jwt);
// it renders question and category lists
const questionListHeadline = screen.queryByText(/Fragen/);
const questionListHeadline = screen.queryAllByText(/Fragen/);
const categoryListHeadline = screen.queryByText(/Kategorien/);
expect(questionListHeadline).not.toBeNull();
expect(questionListHeadline.length).toBeGreaterThan(0);
expect(categoryListHeadline).not.toBeNull();
});
test("displays the candidates page if a candidate is logged in", () => {
const candidateToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg";
localStorage.setItem("token", candidateToken);
renderMainPage();
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_candidate",
person_row_id: 2,
};
renderMainPage(jwt);
const questionListHeadline = screen.queryByText(/Fragen/);
const categoryListHeadline = screen.queryByText(/Kategorien/);
@ -44,22 +58,14 @@ describe("The main page", () => {
});
test("displays the user page if an normal user is logged in", () => {
const userToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", userToken);
renderMainPage();
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_person",
person_row_id: 3,
};
renderMainPage(jwt);
const placeholder = screen.queryByText(/nichts zu sehen/);
expect(placeholder).not.toBeNull();
});
test("displays a link to the loggin page if something is wrong with the token", () => {
const invalidToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", invalidToken);
renderMainPage();
const placeholder = screen.queryByRole("link", { name: /Login Seite/ });
expect(placeholder).not.toBeNull();
});
});

View File

@ -1,57 +1,96 @@
import CustomAppBar from "./CustomAppBar";
import React from "react";
import React, { ReactElement } from "react";
import { makeStyles } from "@material-ui/core/styles";
import { MainPageEditor } from "./MainPageEditor";
import { getJsonWebToken } from "../jwt/jwt";
import { MainPageCandidate } from "./MainPageCandidate";
import { MainPageUser } from "./MainPageUser";
import { Link } from "react-router-dom";
import { Container } from "@material-ui/core";
import { HomePageEditor } from "./HomePageEditor";
import { UserRole } from "../jwt/jwt";
import { HomePageCandidate } from "./HomePageCandidate";
import { HomePageUser } from "./HomePageUser";
import { mainMenuWidth, MainMenu, mainMenuOpen, MenuOption } from "./MainMenu";
import clsx from "clsx";
import QuestionAnswerIcon from "@material-ui/icons/QuestionAnswer";
import PeopleIcon from "@material-ui/icons/People";
import { useReactiveVar } from "@apollo/client";
const useStyles = makeStyles((theme) => ({
appBarSpacer: theme.mixins.toolbar,
content: {
flexGrow: 1,
height: "100vh",
overflow: "auto",
padding: theme.spacing(3),
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginLeft: -mainMenuWidth,
},
contentShift: {
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
marginLeft: 0,
},
invalidTokenContainer: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
root: {
display: "flex",
},
}));
function Main(): React.ReactElement {
interface MainProps {
userRole: UserRole;
userRowId: number;
}
function Main(props: MainProps): ReactElement {
const classes = useStyles();
const getMainPage = () => {
const jwt = getJsonWebToken();
if (jwt) {
switch (jwt.role) {
case "candymat_editor":
return <MainPageEditor />;
case "candymat_candidate":
return <MainPageCandidate personRowId={jwt.person_row_id} />;
case "candymat_person":
return <MainPageUser />;
}
} else {
localStorage.removeItem("token");
return (
<Container className={classes.invalidTokenContainer}>
Du bist nicht eingelogged oder dein Token ist ungültig. Logge dich
erneut ein.
<br />
Zur <Link to={"/login"}>Login Seite</Link>
</Container>
);
const open = useReactiveVar(mainMenuOpen);
const getHomePage = (): ReactElement => {
switch (props.userRole) {
case "candymat_editor":
return <HomePageEditor />;
case "candymat_candidate":
return <HomePageCandidate personRowId={props.userRowId} />;
case "candymat_person":
return <HomePageUser />;
}
};
const getMenuOptions = (): Array<MenuOption> => {
switch (props.userRole) {
case "candymat_editor":
return [
{
title: "Fragen bearbeiten",
path: "/fragen",
icon: <QuestionAnswerIcon />,
},
{
title: "Benutzer verwalten",
path: "/benutzer",
icon: <PeopleIcon />,
},
];
case "candymat_candidate":
return [];
case "candymat_person":
return [];
}
};
return (
<div>
<div className={classes.root}>
<CustomAppBar />
<main className={classes.content}>
<MainMenu options={getMenuOptions()} />
<main
className={clsx(classes.content, {
[classes.contentShift]: open,
})}
>
<div className={classes.appBarSpacer} />
{getMainPage()}
{getHomePage()}
</main>
</div>
);

View File

@ -0,0 +1,83 @@
import React, { ReactElement, useState } from "react";
import { createStyles, IconButton } from "@material-ui/core";
import { makeStyles, Theme } from "@material-ui/core/styles";
import Drawer from "@material-ui/core/Drawer";
import List from "@material-ui/core/List";
import Divider from "@material-ui/core/Divider";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import { makeVar, useReactiveVar } from "@apollo/client";
export const mainMenuWidth = 240;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
drawer: {
width: mainMenuWidth,
flexShrink: 0,
},
drawerPaper: {
width: mainMenuWidth,
},
drawerHeader: {
display: "flex",
alignItems: "center",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
justifyContent: "flex-end",
},
})
);
export const mainMenuOpen = makeVar<boolean>(false);
export interface MenuOption {
title: string;
path: string;
icon: JSX.Element;
}
interface MainMenuProps {
options: Array<MenuOption>;
}
export function MainMenu(props: MainMenuProps): ReactElement {
const classes = useStyles();
const open = useReactiveVar(mainMenuOpen);
const handleClose = () => {
mainMenuOpen(false);
};
return (
<React.Fragment>
<Drawer
className={classes.drawer}
variant="persistent"
anchor="left"
open={open}
classes={{
paper: classes.drawerPaper,
}}
>
<div className={classes.drawerHeader}>
<IconButton onClick={handleClose}>
<ChevronLeftIcon />
</IconButton>
</div>
<Divider />
<List>
{props.options.map((option) => (
<ListItem button key={option.title}>
<ListItemIcon>{option.icon}</ListItemIcon>
<ListItemText primary={option.title} />
</ListItem>
))}
</List>
</Drawer>
</React.Fragment>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import { IconButton, MenuItem } from "@material-ui/core";
import AccountCircle from "@material-ui/icons/AccountCircle";
import Menu from "@material-ui/core/Menu";
import { logoutUser } from "../jwt/jwt";
export function ProfileMenu(): React.ReactElement {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<React.Fragment>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={logoutUser}>Logout</MenuItem>
</Menu>
</React.Fragment>
);
}

View File

@ -57,7 +57,7 @@ export default function SignIn(): React.ReactElement {
onCompleted(data) {
if (data.authenticate.jwtToken) {
localStorage.setItem("token", data.authenticate.jwtToken);
history.replace("/");
window.location.reload();
} else {
setError("Wrong username or password.");
}
@ -83,10 +83,9 @@ export default function SignIn(): React.ReactElement {
noValidate
onSubmit={(event) => {
event.preventDefault();
// fixme: logging?????
login({
variables: { email: email, password: password },
}).catch((error) => console.log(error));
}).catch((error) => setError(error));
}}
>
<TextField

View File

@ -20,7 +20,10 @@ beforeEach(() => localStorage.clear());
describe("The root path /", () => {
test("renders user's home page if they are logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem(
"token",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjEyMjEyODQyLCJpYXQiOjE2MTIwNDAwNDIsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.8Z8iCKq-WHOCyKz4rdrjwcVy7sR5_9dHQZR-lJDLEg4"
);
renderAppAtUrl("/");
expect(() => screen.getByLabelText(/current user/)).not.toThrow();
@ -47,7 +50,10 @@ describe("The /login path", () => {
});
test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem(
"token",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjEyMjEyODQyLCJpYXQiOjE2MTIwNDAwNDIsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.8Z8iCKq-WHOCyKz4rdrjwcVy7sR5_9dHQZR-lJDLEg4"
);
renderAppAtUrl("/login");
expect(() => screen.getByLabelText(/current user/)).not.toThrow();
@ -67,7 +73,10 @@ describe("The /signup path", () => {
});
test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem(
"token",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjEyMjEyODQyLCJpYXQiOjE2MTIwNDAwNDIsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.8Z8iCKq-WHOCyKz4rdrjwcVy7sR5_9dHQZR-lJDLEg4"
);
renderAppAtUrl("/signup");
expect(() => screen.getByLabelText(/current user/)).not.toThrow();

View File

@ -5,17 +5,17 @@ import { MockedProvider } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import { loginMock } from "../backend/mutations/login.mock";
const mockHistoryReplace = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useHistory: () => ({
replace: mockHistoryReplace,
}),
}));
const mockLocationReload = jest.fn();
const { location } = window;
describe("SignIn page", () => {
beforeEach(() => mockHistoryReplace.mockReset());
beforeAll(() => {
delete (window as Partial<Window>).location;
window.location = { ...window.location, reload: mockLocationReload };
});
afterAll(() => (window.location = location));
beforeEach(() => mockLocationReload.mockReset());
test("initial state", () => {
render(
@ -36,7 +36,7 @@ describe("SignIn page", () => {
const button = screen.getByRole("button");
expect(button).not.toBeDisabled();
expect(button).toHaveTextContent("Sign In");
expect(mockHistoryReplace).not.toHaveBeenCalled();
expect(mockLocationReload).not.toHaveBeenCalled();
});
test("successful login", async () => {
@ -59,7 +59,7 @@ describe("SignIn page", () => {
fireEvent.click(button);
await waitFor(() => {
expect(mockHistoryReplace).toHaveBeenCalledWith("/");
expect(mockLocationReload).toHaveBeenCalled();
});
});
@ -89,7 +89,7 @@ describe("SignIn page", () => {
// it displays error text
const errorText = screen.getByText(/Wrong username or password/);
expect(errorText).toBeInTheDocument();
expect(mockHistoryReplace).not.toHaveBeenCalled();
expect(mockLocationReload).not.toHaveBeenCalled();
});
});
});

View File

@ -1,8 +1,11 @@
import { client } from "../backend/helper";
type UserRole = "candymat_editor" | "candymat_candidate" | "candymat_person";
export type UserRole =
| "candymat_editor"
| "candymat_candidate"
| "candymat_person";
interface JwtPayload {
export interface JwtPayload {
role: UserRole;
person_row_id: number;
exp: number;
@ -59,3 +62,5 @@ export const logoutUser = async (): Promise<void> => {
localStorage.removeItem("token");
location.reload();
};
export const isLoggedIn = (): boolean => !!getJsonWebToken();