Introduce routes to navigate as logged-in user

This also enables navigating to an empty user-management page
if logged-in as an editor.
This commit is contained in:
Christoph Lienhard 2021-02-08 00:00:12 +01:00
parent 9944f8a38b
commit ee263f52b1
Signed by: christoph.lienhard
GPG key ID: 6B98870DDC270884
9 changed files with 166 additions and 101 deletions

View file

@ -50,15 +50,15 @@ function App(): React.ReactElement {
return (
<Switch>
<PrivateRoute exact path={"/"}>
{jwt && <Main userRole={jwt.role} userRowId={jwt.person_row_id} />}
</PrivateRoute>
<NotLoggedInOnlyRoute path={"/login"}>
<SignIn />
</NotLoggedInOnlyRoute>
<NotLoggedInOnlyRoute path={"/signup"}>
<SignUp />
</NotLoggedInOnlyRoute>
<PrivateRoute>
{jwt && <Main userRole={jwt.role} userRowId={jwt.person_row_id} />}
</PrivateRoute>
</Switch>
);
}

View file

@ -2,6 +2,10 @@ import { Container } from "@material-ui/core";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import QuestionAnswersList from "./QuestionAnswerList";
import { Route, Switch } from "react-router-dom";
import { PersonRoutes } from "./Main";
import { MenuOption } from "./MainMenu";
import QuestionAnswerIcon from "@material-ui/icons/QuestionAnswer";
const useStyles = makeStyles((theme) => ({
container: {
@ -11,6 +15,18 @@ const useStyles = makeStyles((theme) => ({
},
}));
interface CandidateRoutes extends PersonRoutes {
question: MenuOption;
}
export const candidateRoutes: CandidateRoutes = {
question: {
title: "Fragen beantworten",
path: "/",
icon: <QuestionAnswerIcon />,
},
};
interface HomePageCandidateProps {
personRowId: number;
}
@ -22,7 +38,11 @@ export function HomePageCandidate(
return (
<Container maxWidth="lg" className={classes.container}>
<QuestionAnswersList personRowId={props.personRowId} />
<Switch>
<Route exact path={candidateRoutes.question.path}>
<QuestionAnswersList personRowId={props.personRowId} />
</Route>
</Switch>
</Container>
);
}

View file

@ -4,6 +4,11 @@ import CategoryList from "./CategoryList";
import { Copyright } from "./Copyright";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import { Route, Switch } from "react-router-dom";
import QuestionAnswerIcon from "@material-ui/icons/QuestionAnswer";
import PeopleIcon from "@material-ui/icons/People";
import { MenuOption } from "./MainMenu";
import { PersonRoutes } from "./Main";
const useStyles = makeStyles((theme) => ({
container: {
@ -13,13 +18,38 @@ const useStyles = makeStyles((theme) => ({
},
}));
interface EditorRoutes extends PersonRoutes {
question: MenuOption;
userManagement: MenuOption;
}
export const editorRoutes: EditorRoutes = {
question: {
title: "Fragen bearbeiten",
path: "/",
icon: <QuestionAnswerIcon />,
},
userManagement: {
title: "Benutzer verwalten",
path: "/benutzer",
icon: <PeopleIcon />,
},
};
export function HomePageEditor(): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<QuestionList />
<CategoryList />
<Switch>
<Route exact path={editorRoutes.question.path}>
<QuestionList />
<CategoryList />
</Route>
<Route path={editorRoutes.userManagement.path}>
<div />
</Route>
</Switch>
<Copyright />
</Container>
);

View file

@ -1,6 +1,10 @@
import { Container } from "@material-ui/core";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import { Route, Switch } from "react-router-dom";
import { PersonRoutes } from "./Main";
import { MenuOption } from "./MainMenu";
import HomeIcon from "@material-ui/icons/Home";
const useStyles = makeStyles((theme) => ({
container: {
@ -10,12 +14,28 @@ const useStyles = makeStyles((theme) => ({
},
}));
interface UserRoutes extends PersonRoutes {
home: MenuOption;
}
export const userRoutes: UserRoutes = {
home: {
title: "Home",
path: "/",
icon: <HomeIcon />,
},
};
export function HomePageUser(): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
Sorry, für dich gibt es hier leider nichts zu sehen...
<Switch>
<Route exact path={userRoutes.home.path}>
Sorry, für dich gibt es hier leider nichts zu sehen...
</Route>
</Switch>
</Container>
);
}

View file

@ -1,10 +1,11 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
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";
import { queryAllMenuIconButtons } from "../integration-tests/test-helper";
function renderMainPage(jwt: JwtPayload) {
render(
@ -27,13 +28,14 @@ const baseJwt: JwtPayload = {
person_row_id: 3,
};
describe("The main page", () => {
test("displays the editors page if an editor is logged in", () => {
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_editor",
person_row_id: 1,
};
describe("As an editor, the main page", () => {
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_editor",
person_row_id: 1,
};
test("displays the editor's home page", () => {
renderMainPage(jwt);
// it renders question and category lists
@ -43,7 +45,24 @@ describe("The main page", () => {
expect(categoryListHeadline).not.toBeNull();
});
test("displays the candidates page if a candidate is logged in", () => {
test("has a menu with two entries", async () => {
renderMainPage(jwt);
const menuButton = queryAllMenuIconButtons();
expect(menuButton).toHaveLength(1);
fireEvent.click(menuButton[0]);
// renders the two menu entries for an editor
await waitFor(() => {
expect(
screen.queryAllByRole("button", { name: /fragen|benutzer/i })
).toHaveLength(2);
});
});
});
describe("As a candidate, the main page", () => {
test("displays the candidate's home page ", () => {
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_candidate",
@ -51,13 +70,15 @@ describe("The main page", () => {
};
renderMainPage(jwt);
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).toBeNull();
});
});
test("displays the user page if an normal user is logged in", () => {
describe("As a simple user, the main page", () => {
test("displays the user's home page.", () => {
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_person",

View file

@ -1,14 +1,12 @@
import CustomAppBar from "./CustomAppBar";
import React, { ReactElement } from "react";
import { makeStyles } from "@material-ui/core/styles";
import { HomePageEditor } from "./HomePageEditor";
import { editorRoutes, HomePageEditor } from "./HomePageEditor";
import { UserRole } from "../jwt/jwt";
import { HomePageCandidate } from "./HomePageCandidate";
import { HomePageUser } from "./HomePageUser";
import { mainMenuWidth, MainMenu, mainMenuOpen, MenuOption } from "./MainMenu";
import { candidateRoutes, HomePageCandidate } from "./HomePageCandidate";
import { HomePageUser, userRoutes } from "./HomePageUser";
import { MainMenu, mainMenuOpen, mainMenuWidth, 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) => ({
@ -38,6 +36,10 @@ const useStyles = makeStyles((theme) => ({
},
}));
export interface PersonRoutes {
[id: string]: MenuOption;
}
interface MainProps {
userRole: UserRole;
userRowId: number;
@ -61,22 +63,11 @@ function Main(props: MainProps): ReactElement {
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 />,
},
];
return Object.values(editorRoutes);
case "candymat_candidate":
return [];
return Object.values(candidateRoutes);
case "candymat_person":
return [];
return Object.values(userRoutes);
}
};

View file

@ -9,6 +9,7 @@ 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";
import { useHistory } from "react-router-dom";
export const mainMenuWidth = 240;
@ -46,6 +47,7 @@ interface MainMenuProps {
export function MainMenu(props: MainMenuProps): ReactElement {
const classes = useStyles();
const history = useHistory();
const open = useReactiveVar(mainMenuOpen);
const handleClose = () => {
@ -71,7 +73,11 @@ export function MainMenu(props: MainMenuProps): ReactElement {
<Divider />
<List>
{props.options.map((option) => (
<ListItem button key={option.title}>
<ListItem
button
key={option.title}
onClick={() => history.push(option.path)}
>
<ListItemIcon>{option.icon}</ListItemIcon>
<ListItemText primary={option.title} />
</ListItem>

View file

@ -20,6 +20,34 @@ import {
queryAllEditIconButtons,
} from "./test-helper";
function renderCategoryList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<CategoryList />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialCategoriesToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = [];
await waitFor(() => {
categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
});
return categoryCards;
};
describe("The CategoryList", () => {
test("displays the existing categories, but not the details of it", async () => {
renderCategoryList();
@ -134,31 +162,3 @@ describe("The CategoryList", () => {
});
});
});
function renderCategoryList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<CategoryList />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialCategoriesToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = [];
await waitFor(() => {
categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
});
return categoryCards;
};

View file

@ -13,6 +13,7 @@ import {
CandidatePosition,
getIconForPosition,
} from "../components/CandidatePositionLegend";
import MenuIcon from "@material-ui/icons/Menu";
const memoizedGetIconPath = (icon: JSX.Element) => {
const cache: { path?: string } = {};
@ -32,6 +33,7 @@ const memoizedGetIconPath = (icon: JSX.Element) => {
};
const getEditIconPath = memoizedGetIconPath(<EditIcon />);
const getMenuIconPath = memoizedGetIconPath(<MenuIcon />);
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon />);
const getAddIconPath = memoizedGetIconPath(<AddIcon />);
export const getPositivePositionPath = memoizedGetIconPath(
@ -47,6 +49,7 @@ export const getSkippedPositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.skipped)
);
// sorry, I found no better way to find a specific icon button...
export const queryAllIconButtons = (
iconPath: string,
container?: HTMLElement
@ -60,47 +63,21 @@ export const queryAllIconButtons = (
);
};
// sorry, I found no better way to find a specific icon button...
export const queryAllEditIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getEditIconPath())
);
};
): Array<HTMLElement> => queryAllIconButtons(getEditIconPath(), container);
// sorry, I found no better way to find a specific icon button...
const queryAllDeleteIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getDeleteIconPath())
);
};
): Array<HTMLElement> => queryAllIconButtons(getDeleteIconPath(), container);
// sorry, I found no better way to find a specific icon button...
export const queryAllAddIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getAddIconPath())
);
};
): Array<HTMLElement> => queryAllIconButtons(getAddIconPath(), container);
export const queryAllMenuIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => queryAllIconButtons(getMenuIconPath(), container);
export const expandAccordionAndGetIconButtons = async (
accordion: HTMLElement