+
-
+
+
- {getMainPage()}
+ {getHomePage()}
);
diff --git a/redaktions-app/src/components/MainMenu.tsx b/redaktions-app/src/components/MainMenu.tsx
new file mode 100644
index 0000000..4554947
--- /dev/null
+++ b/redaktions-app/src/components/MainMenu.tsx
@@ -0,0 +1,89 @@
+import React, { ReactElement } 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";
+import { useHistory } from "react-router-dom";
+
+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
(false);
+
+export interface MenuOption {
+ title: string;
+ path: string;
+ icon: JSX.Element;
+}
+
+interface MainMenuProps {
+ options: Array;
+}
+
+export function MainMenu(props: MainMenuProps): ReactElement {
+ const classes = useStyles();
+ const history = useHistory();
+ const open = useReactiveVar(mainMenuOpen);
+
+ const handleClose = () => {
+ mainMenuOpen(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {props.options.map((option) => (
+ history.push(option.path)}
+ >
+ {option.icon}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/redaktions-app/src/components/MainPageCandidate.tsx b/redaktions-app/src/components/MainPageCandidate.tsx
deleted file mode 100644
index 32917a7..0000000
--- a/redaktions-app/src/components/MainPageCandidate.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Container } from "@material-ui/core";
-import React from "react";
-import { makeStyles } from "@material-ui/core/styles";
-import QuestionAnswersList from "./QuestionAnswerList";
-
-const useStyles = makeStyles((theme) => ({
- container: {
- paddingTop: theme.spacing(4),
- paddingBottom: theme.spacing(4),
- flexDirection: "column",
- },
-}));
-
-interface MainPageCandidateProps {
- personRowId: number;
-}
-
-export function MainPageCandidate(
- props: MainPageCandidateProps
-): React.ReactElement {
- const classes = useStyles();
- return (
-
-
-
- );
-}
diff --git a/redaktions-app/src/components/MainPageEditor.tsx b/redaktions-app/src/components/MainPageEditor.tsx
deleted file mode 100644
index 6417d1b..0000000
--- a/redaktions-app/src/components/MainPageEditor.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Container } from "@material-ui/core";
-import QuestionList from "./QuestionList";
-import CategoryList from "./CategoryList";
-import { Copyright } from "./Copyright";
-import React from "react";
-import { makeStyles } from "@material-ui/core/styles";
-
-const useStyles = makeStyles((theme) => ({
- container: {
- paddingTop: theme.spacing(4),
- paddingBottom: theme.spacing(4),
- flexDirection: "column",
- },
-}));
-
-export function MainPageEditor(): React.ReactElement {
- const classes = useStyles();
-
- return (
-
-
-
-
-
- );
-}
diff --git a/redaktions-app/src/components/MainPageUser.tsx b/redaktions-app/src/components/MainPageUser.tsx
deleted file mode 100644
index acf1665..0000000
--- a/redaktions-app/src/components/MainPageUser.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Container } from "@material-ui/core";
-import React from "react";
-import { makeStyles } from "@material-ui/core/styles";
-
-const useStyles = makeStyles((theme) => ({
- container: {
- paddingTop: theme.spacing(4),
- paddingBottom: theme.spacing(4),
- flexDirection: "column",
- },
-}));
-
-export function MainPageUser(): React.ReactElement {
- const classes = useStyles();
-
- return (
-
- Sorry, für dich gibt es hier leider nichts zu sehen...
-
- );
-}
diff --git a/redaktions-app/src/components/PersonCard.tsx b/redaktions-app/src/components/PersonCard.tsx
new file mode 100644
index 0000000..89509d0
--- /dev/null
+++ b/redaktions-app/src/components/PersonCard.tsx
@@ -0,0 +1,59 @@
+import { Avatar, Paper, Typography } from "@material-ui/core";
+import React from "react";
+import { makeStyles } from "@material-ui/core/styles";
+import ChangeRoleMenu from "./ChangeRoleMenu";
+import { UppercaseUserRole } from "../jwt/jwt";
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ width: "100%",
+ marginBottom: theme.spacing(1),
+ display: "flex",
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ avatar: {
+ margin: theme.spacing(1),
+ },
+ title: {
+ marginLeft: theme.spacing(1),
+ width: "100%",
+ },
+ editButton: {
+ margin: theme.spacing(1),
+ },
+}));
+
+interface PersonCardProps {
+ firstName: string;
+ lastName: string;
+ userRole: UppercaseUserRole;
+ userRowId: number;
+ loggedInUserRowId: number;
+}
+
+export default function PersonCard(props: PersonCardProps): React.ReactElement {
+ const classes = useStyles();
+ const userRole = props.userRole;
+ const currentUserRowId = props.userRowId;
+ const getInitials = (): string => {
+ const initials =
+ (props.firstName.charAt(0) || "") + (props.lastName.charAt(0) || "");
+ return initials ? initials.toUpperCase() : "?";
+ };
+
+ const fullName = `${props.firstName} ${props.lastName}`.trim();
+ return (
+
+
+ {getInitials()}
+
+ {fullName}
+
+
+ );
+}
diff --git a/redaktions-app/src/components/ProfileMenu.tsx b/redaktions-app/src/components/ProfileMenu.tsx
new file mode 100644
index 0000000..4d01afa
--- /dev/null
+++ b/redaktions-app/src/components/ProfileMenu.tsx
@@ -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);
+ const open = Boolean(anchorEl);
+
+ const handleMenu = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/redaktions-app/src/components/QuestionAnswerList.tsx b/redaktions-app/src/components/QuestionAnswerList.tsx
index f20e737..79030c4 100644
--- a/redaktions-app/src/components/QuestionAnswerList.tsx
+++ b/redaktions-app/src/components/QuestionAnswerList.tsx
@@ -20,7 +20,7 @@ const useStyles = makeStyles((theme) => ({
}));
interface QuestionAnswerListProps {
- personRowId: number;
+ loggedInPersonRowId: number;
}
export default function QuestionAnswersList(
@@ -32,7 +32,7 @@ export default function QuestionAnswersList(
GetAllQuestionAnswersVariables
>(GET_ALL_QUESTION_ANSWERS, {
variables: {
- personRowId: props.personRowId,
+ personRowId: props.loggedInPersonRowId,
},
}).data?.allQuestions.nodes;
@@ -45,7 +45,7 @@ export default function QuestionAnswersList(
{questionAnswers?.map((question) => (
))}
diff --git a/redaktions-app/src/components/QuestionList.tsx b/redaktions-app/src/components/QuestionList.tsx
index 148cca9..9e3b596 100644
--- a/redaktions-app/src/components/QuestionList.tsx
+++ b/redaktions-app/src/components/QuestionList.tsx
@@ -28,8 +28,9 @@ const useStyles = makeStyles((theme) => ({
}));
export default function QuestionList(): React.ReactElement {
- const questions = useQuery(GET_ALL_QUESTIONS)
- .data?.allQuestions.nodes;
+ const questions =
+ useQuery(GET_ALL_QUESTIONS).data
+ ?.allQuestions.nodes;
const classes = useStyles();
const handleAddButtonClick = () => {
diff --git a/redaktions-app/src/components/SignIn.tsx b/redaktions-app/src/components/SignIn.tsx
index 1061464..ddb1d00 100644
--- a/redaktions-app/src/components/SignIn.tsx
+++ b/redaktions-app/src/components/SignIn.tsx
@@ -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));
}}
>
{
- let result = "Sign-up failed because of the following reason(s): ";
- if (isEmailAlreadyUsed(error)) {
- result += "The E-Mail is already in use. ";
- }
- if (isFirstNameInvalid(error)) {
- result += "The provided 'First Name' is invalid. ";
- }
- if (isLastNameInvalid(error)) {
- result += "The provided 'Last Name' is invalid. ";
- }
- if (isPasswordInvalid(error)) {
- result += "The provided password is invalid. ";
- }
- return result;
-};
-
const isEmailAlreadyUsed = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase();
@@ -53,6 +36,23 @@ const isPasswordInvalid = (error: ApolloError): boolean => {
return errorMessage.includes("invalid") && errorMessage.includes("password");
};
+const parseErrorMessage = (error: ApolloError): string => {
+ let result = "Sign-up failed because of the following reason(s): ";
+ if (isEmailAlreadyUsed(error)) {
+ result += "The E-Mail is already in use. ";
+ }
+ if (isFirstNameInvalid(error)) {
+ result += "The provided 'First Name' is invalid. ";
+ }
+ if (isLastNameInvalid(error)) {
+ result += "The provided 'Last Name' is invalid. ";
+ }
+ if (isPasswordInvalid(error)) {
+ result += "The provided password is invalid. ";
+ }
+ return result;
+};
+
export const errorHandler = (
error: undefined | ApolloError
): undefined | SignUpError => {
diff --git a/redaktions-app/src/components/UserManagement.tsx b/redaktions-app/src/components/UserManagement.tsx
new file mode 100644
index 0000000..af0270c
--- /dev/null
+++ b/redaktions-app/src/components/UserManagement.tsx
@@ -0,0 +1,67 @@
+import React from "react";
+import { Paper, Typography } from "@material-ui/core";
+import { makeStyles } from "@material-ui/core/styles";
+import PersonCard from "./PersonCard";
+import { useQuery } from "@apollo/client";
+import {
+ BasicPersonResponse,
+ GET_PERSONS_SORTED_BY_ROLE,
+ GetPersonsSortedByRoleResponse,
+} from "../backend/queries/person";
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ width: "100%",
+ padding: theme.spacing(1),
+ marginBottom: theme.spacing(3),
+ },
+}));
+
+interface UserManagementProps {
+ loggedInPersonRowId: number;
+}
+
+export function UserManagement(props: UserManagementProps): React.ReactElement {
+ const classes = useStyles();
+ const persons = useQuery(
+ GET_PERSONS_SORTED_BY_ROLE
+ ).data;
+
+ const convertPersonNodeToPersonCard = (
+ person: BasicPersonResponse
+ ): JSX.Element => {
+ return (
+
+ );
+ };
+
+ return (
+
+
+
+ Redaktion
+
+ {persons?.editors.nodes.map(convertPersonNodeToPersonCard)}
+
+
+
+ KandidatInnen
+
+ {persons?.candidates.nodes.map(convertPersonNodeToPersonCard)}
+
+
+
+ Andere registrierte Personen
+
+ {persons?.users.nodes.map(convertPersonNodeToPersonCard)}
+
+
+ );
+}
diff --git a/redaktions-app/src/index.css b/redaktions-app/src/index.css
index 4a1df4d..0473bdc 100644
--- a/redaktions-app/src/index.css
+++ b/redaktions-app/src/index.css
@@ -1,13 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
- "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
- sans-serif;
+ "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+ sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
- monospace;
+ monospace;
}
diff --git a/redaktions-app/src/index.tsx b/redaktions-app/src/index.tsx
index 3ddff26..5bd223f 100644
--- a/redaktions-app/src/index.tsx
+++ b/redaktions-app/src/index.tsx
@@ -7,13 +7,27 @@ import { ApolloProvider } from "@apollo/client";
import { client } from "./backend/helper";
import { BrowserRouter as Router } from "react-router-dom";
import { SnackbarProvider } from "notistack";
+import { createMuiTheme, ThemeProvider } from "@material-ui/core/styles";
+
+const theme = createMuiTheme({
+ palette: {
+ primary: {
+ main: "#59ae2e",
+ },
+ secondary: {
+ main: "#e6007e",
+ },
+ },
+});
ReactDOM.render(
-
-
-
+
+
+
+
+
,
document.getElementById("root")
diff --git a/redaktions-app/src/integration-tests/answer-list.integration.test.tsx b/redaktions-app/src/integration-tests/answer-list.integration.test.tsx
index bf0035d..9a588c1 100644
--- a/redaktions-app/src/integration-tests/answer-list.integration.test.tsx
+++ b/redaktions-app/src/integration-tests/answer-list.integration.test.tsx
@@ -28,6 +28,111 @@ import {
} from "./test-helper";
import QuestionAnswersList from "../components/QuestionAnswerList";
+function renderQuestionAnswerList(additionalMocks?: Array) {
+ const initialMocks = [
+ ...getAllQuestionAnswersMock,
+ ...getAnswerByQuestionAndPersonMock,
+ ];
+ const allMocks = additionalMocks
+ ? [...initialMocks, ...additionalMocks]
+ : initialMocks;
+ return render(
+
+
+
+
+
+
+
+ );
+}
+
+const waitForQuestionsToRender = async (): Promise> => {
+ const numberOfAnswersInMockQuery = questionAnswersMock.length;
+ let questionAnswerCards: Array = [];
+ await waitFor(() => {
+ questionAnswerCards = screen.queryAllByRole("button", {
+ name: /Question [1-3]/,
+ });
+ expect(questionAnswerCards.length).toEqual(numberOfAnswersInMockQuery);
+ });
+ return questionAnswerCards;
+};
+
+const getSaveAnswerButton = (parent: HTMLElement) => {
+ return findByRole(parent, "button", { name: /speichern/i });
+};
+
+const getResetAnswerButton = (parent: HTMLElement) => {
+ return findByRole(parent, "button", { name: /zurücksetzen/i });
+};
+
+interface PositionIconButtons {
+ positive: HTMLElement;
+ neutral: HTMLElement;
+ negative: HTMLElement;
+ skipped: HTMLElement;
+}
+
+const getCandidatePositionButtons = async (
+ parent: HTMLElement
+): Promise => {
+ const labelAboveButtons = await findByText(parent, /Deine Position/i);
+ const parentElement = labelAboveButtons.parentElement as HTMLElement;
+ const positive = queryAllIconButtons(
+ getPositivePositionPath(),
+ parentElement
+ )[0];
+ const neutral = queryAllIconButtons(
+ getNeutralPositionPath(),
+ parentElement
+ )[0];
+ const negative = queryAllIconButtons(
+ getNegativePositionPath(),
+ parentElement
+ )[0];
+ const skipped = queryAllIconButtons(
+ getSkippedPositionPath(),
+ parentElement
+ )[0];
+ expect(positive).toBeDefined();
+ expect(neutral).toBeDefined();
+ expect(negative).toBeDefined();
+ expect(skipped).toBeDefined();
+ return {
+ positive,
+ neutral,
+ negative,
+ skipped,
+ };
+};
+
+interface AccordionAnswerSection {
+ positionIconButtons: PositionIconButtons;
+ textField: HTMLElement;
+ saveButton: HTMLElement;
+ resetButton: HTMLElement;
+}
+
+const expandAccordionAndGetAnswerSection = async (
+ accordionExpandArea: HTMLElement
+): Promise => {
+ fireEvent.click(accordionExpandArea);
+ let fullAccordion = accordionExpandArea.parentElement;
+ expect(fullAccordion).not.toBeNull();
+ fullAccordion = fullAccordion as HTMLElement;
+ const textField = await findByRole(fullAccordion, "textbox");
+ const saveButton = await getSaveAnswerButton(fullAccordion);
+ const resetButton = await getResetAnswerButton(fullAccordion);
+ const positionIconButtons = await getCandidatePositionButtons(fullAccordion);
+ return {
+ positionIconButtons,
+ textField,
+ saveButton,
+ resetButton,
+ };
+};
+
describe("The AnswerList", () => {
test("displays the existing answers, but not the details of it", async () => {
renderQuestionAnswerList();
@@ -145,108 +250,3 @@ describe("The AnswerList", () => {
);
});
});
-
-function renderQuestionAnswerList(additionalMocks?: Array) {
- const initialMocks = [
- ...getAllQuestionAnswersMock,
- ...getAnswerByQuestionAndPersonMock,
- ];
- const allMocks = additionalMocks
- ? [...initialMocks, ...additionalMocks]
- : initialMocks;
- return render(
-
-
-
-
-
-
-
- );
-}
-
-const waitForQuestionsToRender = async (): Promise> => {
- const numberOfAnswersInMockQuery = questionAnswersMock.length;
- let questionAnswerCards: Array = [];
- await waitFor(() => {
- questionAnswerCards = screen.queryAllByRole("button", {
- name: /Question [1-3]/,
- });
- expect(questionAnswerCards.length).toEqual(numberOfAnswersInMockQuery);
- });
- return questionAnswerCards;
-};
-
-const getSaveAnswerButton = (parent: HTMLElement) => {
- return findByRole(parent, "button", { name: /speichern/i });
-};
-
-const getResetAnswerButton = (parent: HTMLElement) => {
- return findByRole(parent, "button", { name: /zurücksetzen/i });
-};
-
-interface PositionIconButtons {
- positive: HTMLElement;
- neutral: HTMLElement;
- negative: HTMLElement;
- skipped: HTMLElement;
-}
-
-const getCandidatePositionButtons = async (
- parent: HTMLElement
-): Promise => {
- const labelAboveButtons = await findByText(parent, /Deine Position/i);
- const parentElement = labelAboveButtons.parentElement as HTMLElement;
- const positive = queryAllIconButtons(
- getPositivePositionPath(),
- parentElement
- )[0];
- const neutral = queryAllIconButtons(
- getNeutralPositionPath(),
- parentElement
- )[0];
- const negative = queryAllIconButtons(
- getNegativePositionPath(),
- parentElement
- )[0];
- const skipped = queryAllIconButtons(
- getSkippedPositionPath(),
- parentElement
- )[0];
- expect(positive).toBeDefined();
- expect(neutral).toBeDefined();
- expect(negative).toBeDefined();
- expect(skipped).toBeDefined();
- return {
- positive,
- neutral,
- negative,
- skipped,
- };
-};
-
-interface AccordionAnswerSection {
- positionIconButtons: PositionIconButtons;
- textField: HTMLElement;
- saveButton: HTMLElement;
- resetButton: HTMLElement;
-}
-
-const expandAccordionAndGetAnswerSection = async (
- accordionExpandArea: HTMLElement
-): Promise => {
- fireEvent.click(accordionExpandArea);
- let fullAccordion = accordionExpandArea.parentElement;
- expect(fullAccordion).not.toBeNull();
- fullAccordion = fullAccordion as HTMLElement;
- const textField = await findByRole(fullAccordion, "textbox");
- const saveButton = await getSaveAnswerButton(fullAccordion);
- const resetButton = await getResetAnswerButton(fullAccordion);
- const positionIconButtons = await getCandidatePositionButtons(fullAccordion);
- return {
- positionIconButtons,
- textField,
- saveButton,
- resetButton,
- };
-};
diff --git a/redaktions-app/src/integration-tests/app-routing.integration.test.tsx b/redaktions-app/src/integration-tests/app-routing.integration.test.tsx
index c9cadfe..5fd6dfe 100644
--- a/redaktions-app/src/integration-tests/app-routing.integration.test.tsx
+++ b/redaktions-app/src/integration-tests/app-routing.integration.test.tsx
@@ -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();
diff --git a/redaktions-app/src/integration-tests/category-list.integration.test.tsx b/redaktions-app/src/integration-tests/category-list.integration.test.tsx
index d625748..3db3d1b 100644
--- a/redaktions-app/src/integration-tests/category-list.integration.test.tsx
+++ b/redaktions-app/src/integration-tests/category-list.integration.test.tsx
@@ -20,6 +20,34 @@ import {
queryAllEditIconButtons,
} from "./test-helper";
+function renderCategoryList(additionalMocks?: Array) {
+ const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
+ const allMocks = additionalMocks
+ ? [...initialMocks, ...additionalMocks]
+ : initialMocks;
+ return render(
+
+
+
+
+
+
+
+ );
+}
+
+const waitForInitialCategoriesToRender = async (): Promise<
+ Array
+> => {
+ const numberOfCategoriesInMockQuery = categoryNodesMock.length;
+ let categoryCards: Array = [];
+ 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) {
- const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
- const allMocks = additionalMocks
- ? [...initialMocks, ...additionalMocks]
- : initialMocks;
- return render(
-
-
-
-
-
-
-
- );
-}
-
-const waitForInitialCategoriesToRender = async (): Promise<
- Array
-> => {
- const numberOfCategoriesInMockQuery = categoryNodesMock.length;
- let categoryCards: Array = [];
- await waitFor(() => {
- categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
- expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
- });
- return categoryCards;
-};
diff --git a/redaktions-app/src/integration-tests/question-list.integration.test.tsx b/redaktions-app/src/integration-tests/question-list.integration.test.tsx
index 954c9f1..8741256 100644
--- a/redaktions-app/src/integration-tests/question-list.integration.test.tsx
+++ b/redaktions-app/src/integration-tests/question-list.integration.test.tsx
@@ -21,6 +21,40 @@ import {
queryAllEditIconButtons,
} from "./test-helper";
+function renderQuestionList(additionalMocks?: Array) {
+ const initialMocks = [
+ ...getAllQuestionsMock,
+ ...getQuestionByIdMock,
+ ...getAllCategoriesMock,
+ ];
+ const allMocks = additionalMocks
+ ? [...initialMocks, ...additionalMocks]
+ : initialMocks;
+ return render(
+
+
+
+
+
+
+
+ );
+}
+
+const waitForInitialQuestionsToRender = async (): Promise<
+ Array
+> => {
+ const numberOfQuestionsInMockQuery = questionNodesMock.length;
+ let questionCards: Array = [];
+ await waitFor(() => {
+ questionCards = screen.queryAllByRole("button", {
+ name: /Question [1-3]\?/,
+ });
+ expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
+ });
+ return questionCards;
+};
+
describe("The QuestionList", () => {
test("displays the existing questions, but not the details of it", async () => {
renderQuestionList();
@@ -138,37 +172,3 @@ describe("The QuestionList", () => {
});
});
});
-
-function renderQuestionList(additionalMocks?: Array) {
- const initialMocks = [
- ...getAllQuestionsMock,
- ...getQuestionByIdMock,
- ...getAllCategoriesMock,
- ];
- const allMocks = additionalMocks
- ? [...initialMocks, ...additionalMocks]
- : initialMocks;
- return render(
-
-
-
-
-
-
-
- );
-}
-
-const waitForInitialQuestionsToRender = async (): Promise<
- Array
-> => {
- const numberOfQuestionsInMockQuery = questionNodesMock.length;
- let questionCards: Array = [];
- await waitFor(() => {
- questionCards = screen.queryAllByRole("button", {
- name: /Question [1-3]\?/,
- });
- expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
- });
- return questionCards;
-};
diff --git a/redaktions-app/src/integration-tests/sign-in.integration.test.tsx b/redaktions-app/src/integration-tests/sign-in.integration.test.tsx
index 4a7a21b..4d51a82 100644
--- a/redaktions-app/src/integration-tests/sign-in.integration.test.tsx
+++ b/redaktions-app/src/integration-tests/sign-in.integration.test.tsx
@@ -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).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();
});
});
});
diff --git a/redaktions-app/src/integration-tests/test-helper.tsx b/redaktions-app/src/integration-tests/test-helper.tsx
index 30fb529..2b15188 100644
--- a/redaktions-app/src/integration-tests/test-helper.tsx
+++ b/redaktions-app/src/integration-tests/test-helper.tsx
@@ -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();
+const getMenuIconPath = memoizedGetIconPath();
const getDeleteIconPath = memoizedGetIconPath();
const getAddIconPath = memoizedGetIconPath();
export const getPositivePositionPath = memoizedGetIconPath(
@@ -47,60 +49,36 @@ 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
): HTMLElement[] => {
- return (container
- ? queryAllByRole(container, "button")
- : screen.queryAllByRole("button")
+ return (
+ container
+ ? queryAllByRole(container, "button")
+ : screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") && button.innerHTML.includes(iconPath)
);
};
-// sorry, I found no better way to find a specific icon button...
export const queryAllEditIconButtons = (
container?: HTMLElement
-): Array => {
- return (container
- ? queryAllByRole(container, "button")
- : screen.queryAllByRole("button")
- ).filter(
- (button) =>
- button.innerHTML.includes("svg") &&
- button.innerHTML.includes(getEditIconPath())
- );
-};
+): Array => queryAllIconButtons(getEditIconPath(), container);
-// sorry, I found no better way to find a specific icon button...
const queryAllDeleteIconButtons = (
container?: HTMLElement
-): Array => {
- return (container
- ? queryAllByRole(container, "button")
- : screen.queryAllByRole("button")
- ).filter(
- (button) =>
- button.innerHTML.includes("svg") &&
- button.innerHTML.includes(getDeleteIconPath())
- );
-};
+): Array => queryAllIconButtons(getDeleteIconPath(), container);
-// sorry, I found no better way to find a specific icon button...
export const queryAllAddIconButtons = (
container?: HTMLElement
-): Array => {
- return (container
- ? queryAllByRole(container, "button")
- : screen.queryAllByRole("button")
- ).filter(
- (button) =>
- button.innerHTML.includes("svg") &&
- button.innerHTML.includes(getAddIconPath())
- );
-};
+): Array => queryAllIconButtons(getAddIconPath(), container);
+
+export const queryAllMenuIconButtons = (
+ container?: HTMLElement
+): Array => queryAllIconButtons(getMenuIconPath(), container);
export const expandAccordionAndGetIconButtons = async (
accordion: HTMLElement
diff --git a/redaktions-app/src/integration-tests/user-management.integration.test.tsx b/redaktions-app/src/integration-tests/user-management.integration.test.tsx
new file mode 100644
index 0000000..5a561f5
--- /dev/null
+++ b/redaktions-app/src/integration-tests/user-management.integration.test.tsx
@@ -0,0 +1,70 @@
+import React from "react";
+import { findByText, render, screen } from "@testing-library/react";
+import { MockedProvider, MockedResponse } from "@apollo/client/testing";
+import { MemoryRouter } from "react-router-dom";
+import { SnackbarProvider } from "notistack";
+import { UserManagement } from "../components/UserManagement";
+import {
+ getPersonsSortedByRoleAllFilledMock,
+ getPersonsSortedByRoleNoCandidatesMock,
+} from "../backend/queries/person.mock";
+
+function renderUserManagementPage(mocks: Array) {
+ return render(
+
+
+
+
+
+
+
+ );
+}
+
+describe("The UserManagement page", () => {
+ test(" displays all registered persons sorted by role.", async () => {
+ renderUserManagementPage(getPersonsSortedByRoleAllFilledMock);
+
+ const editorsSectionTitle = await screen.findByText(/Redaktion/);
+ const editorsSection = editorsSectionTitle.parentElement as HTMLElement;
+ const editor = await findByText(editorsSection, /Erika Mustermann/);
+ expect(editor).not.toBeNull();
+
+ const candidatesSectionTitle = await screen.findByText(/KandidatInnen/);
+ const candidatesSection =
+ candidatesSectionTitle.parentElement as HTMLElement;
+ const candidate1 = await findByText(candidatesSection, /Max Mustermann/);
+ const candidate2 = await findByText(candidatesSection, /Tricia McMillan/);
+ expect(candidate1).not.toBeNull();
+ expect(candidate2).not.toBeNull();
+
+ /* const otherUsersSectionTitle = await screen.findByText(/Andere/);
+ const otherUsersSection = otherUsersSectionTitle.parentElement as HTMLElement;
+ const otherUser = await findByText(otherUsersSection, /Happy User/);
+ expect(otherUser).not.toBeNull(); */
+ });
+
+ test(" displays all registered persons sorted by role even though there are no candidates.", async () => {
+ renderUserManagementPage(getPersonsSortedByRoleNoCandidatesMock);
+
+ const editorsSectionTitle = await screen.findByText(/Redaktion/);
+ const editorsSection = editorsSectionTitle.parentElement as HTMLElement;
+ const editor = await findByText(editorsSection, /Erika Mustermann/);
+ expect(editor).not.toBeNull();
+
+ const candidatesSectionTitle = await screen.findByText("KandidatInnen");
+ const candidatesSection =
+ candidatesSectionTitle.parentElement as HTMLElement;
+ expect(candidatesSection.childElementCount).toBe(1);
+
+ const otherUsersSectionTitle = await screen.findByText(/Andere/);
+ const otherUsersSection =
+ otherUsersSectionTitle.parentElement as HTMLElement;
+ const otherUser1 = await findByText(otherUsersSection, /Max Mustermann/);
+ const otherUser2 = await findByText(otherUsersSection, /Tricia McMillan/);
+ const otherUser3 = await findByText(otherUsersSection, /Happy User/);
+ expect(otherUser1).not.toBeNull();
+ expect(otherUser2).not.toBeNull();
+ expect(otherUser3).not.toBeNull();
+ });
+});
diff --git a/redaktions-app/src/jwt/jwt.ts b/redaktions-app/src/jwt/jwt.ts
index 53bbb35..beb8c78 100644
--- a/redaktions-app/src/jwt/jwt.ts
+++ b/redaktions-app/src/jwt/jwt.ts
@@ -1,10 +1,53 @@
+import { client } from "../backend/helper";
+
+type Claim = "role" | "person_row_id" | "exp" | "iat" | "aud" | "iss";
+
+export type UppercaseUserRole =
+ | "CANDYMAT_PERSON"
+ | "CANDYMAT_EDITOR"
+ | "CANDYMAT_CANDIDATE";
+
+export type UserRole =
+ | "candymat_editor"
+ | "candymat_candidate"
+ | "candymat_person";
+
+export interface JwtPayload {
+ role: UserRole;
+ person_row_id: number;
+ exp: number;
+ iat: number;
+ aud: "postgraphile";
+ iss: "postgraphile";
+}
+
+const CLAIMS: Claim[] = ["role", "person_row_id", "exp", "iat", "aud", "iss"];
+
+export const UPPERCASE_USER_ROLES: UppercaseUserRole[] = [
+ "CANDYMAT_PERSON",
+ "CANDYMAT_EDITOR",
+ "CANDYMAT_CANDIDATE",
+];
+
+export const USER_ROLES: UserRole[] = [
+ "candymat_editor",
+ "candymat_candidate",
+ "candymat_person",
+];
+
export const getRawJsonWebToken = (): string | null => {
return localStorage.getItem("token");
};
-export const getJsonWebToken = (): JwtPayload | null => {
- const rawToken = getRawJsonWebToken();
- return rawToken ? parseJwt(rawToken) : null;
+export const isJwtPayloadValid = (jwtPayload: unknown): boolean => {
+ const jwt = Object(jwtPayload);
+ return (
+ CLAIMS.every((claim) => Object.keys(jwt).includes(claim)) &&
+ USER_ROLES.includes(jwt.role) &&
+ typeof jwt.person_row_id === "number" &&
+ typeof jwt.exp === "number" &&
+ typeof jwt.iat === "number"
+ );
};
export const parseJwt = (token: string): JwtPayload | null => {
@@ -24,26 +67,19 @@ export const parseJwt = (token: string): JwtPayload | null => {
}
};
-export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => {
- return (
- claims.every((claim) => Object.keys(jwtPayload).includes(claim)) &&
- userRoles.includes(jwtPayload.role) &&
- typeof jwtPayload.person_row_id === "number" &&
- typeof jwtPayload.exp === "number" &&
- typeof jwtPayload.iat === "number"
- );
+export const getJsonWebToken = (): JwtPayload | null => {
+ const rawToken = getRawJsonWebToken();
+ return rawToken ? parseJwt(rawToken) : null;
};
-const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"];
-const userRoles = ["candymat_editor", "candymat_candidate", "candymat_person"];
+export const logoutUser = async (): Promise => {
+ try {
+ await client.cache.reset();
+ } catch (e) {
+ console.error(e);
+ }
+ localStorage.removeItem("token");
+ window.location.reload();
+};
-interface JwtPayload {
- role: UserRole;
- person_row_id: number;
- exp: number;
- iat: number;
- aud: "postgraphile";
- iss: "postgraphile";
-}
-
-type UserRole = "candymat_editor" | "candymat_candidate" | "candymat_person";
+export const isLoggedIn = (): boolean => !!getJsonWebToken();
diff --git a/redaktions-app/src/serviceWorker.ts b/redaktions-app/src/serviceWorker.ts
index b283889..3d1cd22 100644
--- a/redaktions-app/src/serviceWorker.ts
+++ b/redaktions-app/src/serviceWorker.ts
@@ -25,40 +25,6 @@ type Config = {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
-export function register(config?: Config): void {
- if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
- // The URL constructor is available in all browsers that support SW.
- const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
- if (publicUrl.origin !== window.location.origin) {
- // Our service worker won't work if PUBLIC_URL is on a different origin
- // from what our page is served on. This might happen if a CDN is used to
- // serve assets; see https://github.com/facebook/create-react-app/issues/2374
- return;
- }
-
- window.addEventListener("load", () => {
- const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
-
- if (isLocalhost) {
- // This is running on localhost. Let's check if a service worker still exists or not.
- checkValidServiceWorker(swUrl, config);
-
- // Add some additional logging to localhost, pointing developers to the
- // service worker/PWA documentation.
- navigator.serviceWorker.ready.then(() => {
- console.log(
- "This web app is being served cache-first by a service " +
- "worker. To learn more, visit https://bit.ly/CRA-PWA"
- );
- });
- } else {
- // Is not localhost. Just register service worker
- registerValidSW(swUrl, config);
- }
- });
- }
-}
-
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
@@ -133,6 +99,40 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
});
}
+export function register(config?: Config): void {
+ if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener("load", () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (isLocalhost) {
+ // This is running on localhost. Let's check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl, config);
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.log(
+ "This web app is being served cache-first by a service " +
+ "worker. To learn more, visit https://bit.ly/CRA-PWA"
+ );
+ });
+ } else {
+ // Is not localhost. Just register service worker
+ registerValidSW(swUrl, config);
+ }
+ });
+ }
+}
+
export function unregister(): void {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
diff --git a/redaktions-app/tsconfig.json b/redaktions-app/tsconfig.json
index af10394..fbce9be 100644
--- a/redaktions-app/tsconfig.json
+++ b/redaktions-app/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -13,7 +17,10 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
- "jsx": "react"
+ "jsx": "react",
+ "noFallthroughCasesInSwitch": true
},
- "include": ["src"]
+ "include": [
+ "src"
+ ]
}