Merge pull request 'feature/#20' (#24) from feature/#20 into develop
Reviewed-on: Netzbegruenung/candymat#24
This commit is contained in:
commit
f66f8adecb
|
@ -19,12 +19,14 @@ create table candymat_data_privat.person_account
|
|||
email character varying(320) not null unique check (email ~* '^.+@.+\..+$'),
|
||||
password_hash character varying(256) not null
|
||||
);
|
||||
|
||||
alter table candymat_data.person
|
||||
enable row level security;
|
||||
create policy update_person on candymat_data.person for update to candymat_person
|
||||
with check (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
|
||||
create policy delete_person on candymat_data.person for delete to candymat_person
|
||||
using (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
|
||||
|
||||
-- The following enables viewing candidates and editors information for every person.
|
||||
-- This may be changed to only enable registered (and verified) persons.
|
||||
create policy select_person_public
|
||||
|
@ -32,3 +34,10 @@ create policy select_person_public
|
|||
for select
|
||||
to candymat_anonymous, candymat_person -- maybe change to candymat_person only in the future
|
||||
using (role in ('candymat_editor', 'candymat_candidate'));
|
||||
|
||||
-- Editors can see all registered persons in order to elevate their privileges
|
||||
create policy select_person_editor
|
||||
on candymat_data.person
|
||||
for select
|
||||
to candymat_editor
|
||||
using (true);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
create extension if not exists "pgcrypto";
|
||||
|
||||
|
||||
-- Define JWT claim structure
|
||||
drop type if exists candymat_data.jwt_token cascade;
|
||||
create type candymat_data.jwt_token as
|
||||
(
|
||||
role text,
|
||||
|
@ -8,6 +10,9 @@ create type candymat_data.jwt_token as
|
|||
exp bigint
|
||||
);
|
||||
|
||||
|
||||
-- Function to get the currently logged-in person
|
||||
drop function if exists candymat_data.current_person;
|
||||
create function candymat_data.current_person(
|
||||
) returns candymat_data.person as
|
||||
$$
|
||||
|
@ -17,6 +22,9 @@ where row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::in
|
|||
$$ language sql stable;
|
||||
grant execute on function candymat_data.current_person() to candymat_person;
|
||||
|
||||
|
||||
-- Function to register a new user
|
||||
drop function if exists candymat_data.register_person;
|
||||
create function candymat_data.register_person(
|
||||
first_name text,
|
||||
last_name text,
|
||||
|
@ -47,9 +55,11 @@ begin
|
|||
end ;
|
||||
$$ language plpgsql strict
|
||||
security definer;
|
||||
|
||||
grant execute on function candymat_data.register_person(text, text, text, text) to candymat_anonymous;
|
||||
|
||||
|
||||
-- Authenticate: Login for user
|
||||
drop function if exists candymat_data.authenticate;
|
||||
create function candymat_data.authenticate(
|
||||
email text,
|
||||
password text
|
||||
|
@ -80,26 +90,24 @@ $$ language plpgsql strict
|
|||
security definer;
|
||||
grant execute on function candymat_data.authenticate(text, text) to candymat_anonymous, candymat_person;
|
||||
|
||||
|
||||
-- Change role: Changes role for a given user. Only editors are allowed to use it.
|
||||
drop function if exists candymat_data.change_role;
|
||||
create function candymat_data.change_role(
|
||||
person_row_id integer,
|
||||
new_role candymat_data.role
|
||||
)
|
||||
returns table
|
||||
(
|
||||
first_name text,
|
||||
last_name text,
|
||||
role candymat_data.role
|
||||
)
|
||||
as
|
||||
returns candymat_data.person as
|
||||
$$
|
||||
declare
|
||||
person candymat_data.person;
|
||||
begin
|
||||
update candymat_data.person
|
||||
set role = new_role
|
||||
where candymat_data.person.row_id = $1;
|
||||
where candymat_data.person.row_id = $1
|
||||
returning * into person;
|
||||
|
||||
return query select candymat_data.person.first_name::text, candymat_data.person.last_name::text, new_role
|
||||
from candymat_data.person
|
||||
where person.row_id = person_row_id;
|
||||
return person;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
$$ language plpgsql strict security definer;
|
||||
grant execute on function candymat_data.change_role(integer, candymat_data.role) to candymat_editor;
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit d414b95c1c664adcd5149aee8eac4436b40d7dfb
|
||||
Subproject commit 4e290449cb4b5821f5542d1ea6a9a629b99e6aa3
|
|
@ -1,10 +1,11 @@
|
|||
env:
|
||||
browser: true
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
- 'plugin:react/recommended'
|
||||
- 'plugin:@typescript-eslint/recommended'
|
||||
parser: '@typescript-eslint/parser'
|
||||
- "eslint:recommended"
|
||||
- "plugin:react/recommended"
|
||||
- "plugin:@typescript-eslint/recommended"
|
||||
- "plugin:prettier/recommended"
|
||||
parser: "@typescript-eslint/parser"
|
||||
parserOptions:
|
||||
ecmaFeatures:
|
||||
jsx: true
|
||||
|
@ -12,8 +13,11 @@ parserOptions:
|
|||
sourceType: module
|
||||
plugins:
|
||||
- react
|
||||
- '@typescript-eslint'
|
||||
rules: {}
|
||||
- "@typescript-eslint"
|
||||
rules:
|
||||
no-use-before-define: off
|
||||
"@typescript-eslint/no-use-before-define":
|
||||
- error
|
||||
settings:
|
||||
react:
|
||||
version: detect
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Redaktions App
|
||||
|
||||
The "Redaktions App" or editor's app is the main gateway for editors and candidates to alter the database,
|
||||
e.g. adding new questions (editors) and answering them (candidates).
|
||||
The "Redaktions App" or editor's app is the main gateway for editors and candidates to alter the database, e.g. adding
|
||||
new questions (editors) and answering them (candidates).
|
||||
|
||||
## Development
|
||||
|
||||
|
@ -18,8 +18,8 @@ The app is written in typescript and react and uses apollo to query the backend
|
|||
```shell script
|
||||
docker-compose up
|
||||
```
|
||||
which will start the whole setup including this app in a dockerfile.
|
||||
However, rebuilding and restarting this image can be cumbersome and is not necessary in the development setup.
|
||||
which will start the whole setup including this app in a dockerfile. However, rebuilding and restarting this image can
|
||||
be cumbersome and is not necessary in the development setup.
|
||||
- Instead run
|
||||
```shell script
|
||||
npm start
|
||||
|
@ -38,13 +38,14 @@ Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
|||
The page will reload if you make edits.<br>
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
Running the app without the backend server makes little sense.
|
||||
Start it under [http://localhost:5000](http://localhost:5000) as specified in the Readme of the backend server (../backend)
|
||||
Running the app without the backend server makes little sense. Start it
|
||||
under [http://localhost:5000](http://localhost:5000) as specified in the Readme of the backend server (../backend)
|
||||
|
||||
##### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br>
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more
|
||||
information.
|
||||
|
||||
##### `npm run build`
|
||||
|
||||
|
@ -60,8 +61,13 @@ See the section about [deployment](https://facebook.github.io/create-react-app/d
|
|||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will
|
||||
remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right
|
||||
into your project so you have full control over them. All of the commands except `eject` will still work, but they will
|
||||
point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you
|
||||
shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t
|
||||
customize it when you are ready for it.
|
||||
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
File diff suppressed because it is too large
Load Diff
|
@ -9,12 +9,12 @@
|
|||
"@material-ui/lab": "^4.0.0-alpha.57",
|
||||
"@testing-library/user-event": "^7.2.1",
|
||||
"graphql": "^15.3.0",
|
||||
"react": "^16.13.1",
|
||||
"notistack": "^1.0.3",
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "^3.4.4",
|
||||
"typescript": "^3.8",
|
||||
"notistack": "^1.0.3"
|
||||
"react-scripts": "^4.0.2",
|
||||
"typescript": "^3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
|
@ -26,12 +26,13 @@
|
|||
"@types/react-router-dom": "^5.1.5",
|
||||
"@typescript-eslint/eslint-plugin": "^4.12.0",
|
||||
"@typescript-eslint/parser": "^4.12.0",
|
||||
"eslint-config-prettier": "^7.1.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"husky": "^4.3.6",
|
||||
"jest-environment-jsdom-sixteen": "^1.0.3",
|
||||
"lint-staged": "^10.5.3",
|
||||
"prettier": "2.2.1"
|
||||
"prettier": "^2.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
@ -44,7 +45,7 @@
|
|||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged && npm test"
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
|
|
|
@ -36,3 +36,7 @@
|
|||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.MuiAppBar-colorPrimary {
|
||||
background-color: green;
|
||||
}
|
||||
|
|
|
@ -1,27 +1,10 @@
|
|||
import "./App.css";
|
||||
import React from "react";
|
||||
import "./App.css";
|
||||
import Main from "./components/Main";
|
||||
import { Redirect, Route, RouteProps, Switch } from "react-router-dom";
|
||||
import SignIn from "./components/SignIn";
|
||||
import SignUp from "./components/SignUp";
|
||||
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
<Switch>
|
||||
<PrivateRoute exact path={"/"}>
|
||||
<Main />
|
||||
</PrivateRoute>
|
||||
<NotLoggedInOnlyRoute path={"/login"}>
|
||||
<SignIn />
|
||||
</NotLoggedInOnlyRoute>
|
||||
<NotLoggedInOnlyRoute path={"/signup"}>
|
||||
<SignUp />
|
||||
</NotLoggedInOnlyRoute>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export const isLoggedIn = (): boolean => !!localStorage.getItem("token");
|
||||
import { getJsonWebToken, isLoggedIn } from "./jwt/jwt";
|
||||
|
||||
function PrivateRoute({ children, ...rest }: RouteProps) {
|
||||
return (
|
||||
|
@ -62,4 +45,24 @@ function NotLoggedInOnlyRoute({ children, ...rest }: RouteProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function App(): React.ReactElement {
|
||||
const jwt = getJsonWebToken();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<NotLoggedInOnlyRoute path={"/login"}>
|
||||
<SignIn />
|
||||
</NotLoggedInOnlyRoute>
|
||||
<NotLoggedInOnlyRoute path={"/signup"}>
|
||||
<SignUp />
|
||||
</NotLoggedInOnlyRoute>
|
||||
<PrivateRoute>
|
||||
{jwt && (
|
||||
<Main userRole={jwt.role} loggedInUserRowId={jwt.person_row_id} />
|
||||
)}
|
||||
</PrivateRoute>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
|
@ -1,11 +1,23 @@
|
|||
import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
|
||||
import { setContext } from "@apollo/client/link/context";
|
||||
import { getRawJsonWebToken } from "../jwt/jwt";
|
||||
import { getRawJsonWebToken, logoutUser } from "../jwt/jwt";
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
import { ServerError } from "@apollo/client/link/utils";
|
||||
import { ServerParseError } from "@apollo/client/link/http";
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: "http://localhost:5433/graphql",
|
||||
});
|
||||
|
||||
const errorLink = onError(({ networkError }) => {
|
||||
if (
|
||||
networkError &&
|
||||
(networkError as ServerError | ServerParseError).statusCode === 401
|
||||
) {
|
||||
logoutUser();
|
||||
}
|
||||
});
|
||||
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
const token = getRawJsonWebToken();
|
||||
return token
|
||||
|
@ -20,6 +32,6 @@ const authLink = setContext((_, { headers }) => {
|
|||
|
||||
export const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: authLink.concat(httpLink),
|
||||
link: errorLink.concat(authLink.concat(httpLink)),
|
||||
connectToDevTools: true,
|
||||
});
|
||||
|
|
|
@ -103,18 +103,17 @@ const getDeletedCategoryMock = (): DeleteCategoryPayload | null => {
|
|||
: null;
|
||||
};
|
||||
|
||||
export const deleteCategoryMock: Array<
|
||||
MockedResponse<DeleteCategoryResponse>
|
||||
> = [
|
||||
{
|
||||
request: {
|
||||
query: DELETE_CATEGORY,
|
||||
variables: deleteCategoryVariables,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
deleteCategory: getDeletedCategoryMock(),
|
||||
export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> =
|
||||
[
|
||||
{
|
||||
request: {
|
||||
query: DELETE_CATEGORY,
|
||||
variables: deleteCategoryVariables,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
deleteCategory: getDeletedCategoryMock(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
|
|
@ -116,18 +116,17 @@ const getDeletedQuestionMock = (): DeleteQuestionPayload | null => {
|
|||
: null;
|
||||
};
|
||||
|
||||
export const deleteQuestionMock: Array<
|
||||
MockedResponse<DeleteQuestionResponse>
|
||||
> = [
|
||||
{
|
||||
request: {
|
||||
query: DELETE_QUESTION,
|
||||
variables: deleteQuestionVariables,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
deleteQuestion: getDeletedQuestionMock(),
|
||||
export const deleteQuestionMock: Array<MockedResponse<DeleteQuestionResponse>> =
|
||||
[
|
||||
{
|
||||
request: {
|
||||
query: DELETE_QUESTION,
|
||||
variables: deleteQuestionVariables,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
deleteQuestion: getDeletedQuestionMock(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { gql } from "@apollo/client";
|
||||
import { UppercaseUserRole } from "../../jwt/jwt";
|
||||
import { BasicPersonFragment, BasicPersonResponse } from "../queries/person";
|
||||
|
||||
export const CHANGE_ROLE = gql`
|
||||
mutation ChangeRole($personRowId: Int!, $newRole: Role!) {
|
||||
changeRole(input: { personRowId: $personRowId, newRole: $newRole }) {
|
||||
person {
|
||||
...BasicPersonFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${BasicPersonFragment}
|
||||
`;
|
||||
|
||||
export interface ChangeRoleVariables {
|
||||
personRowId: number;
|
||||
newRole: UppercaseUserRole;
|
||||
}
|
||||
|
||||
export interface ChangeRoleResponse {
|
||||
__typename: "Mutation";
|
||||
changeRole: {
|
||||
person?: BasicPersonResponse;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
import { MockedResponse } from "@apollo/client/testing";
|
||||
import {
|
||||
GET_PERSONS_SORTED_BY_ROLE,
|
||||
GetPersonsSortedByRoleResponse,
|
||||
} from "./person";
|
||||
|
||||
export const getPersonsByRoleAllFilledData: GetPersonsSortedByRoleResponse = {
|
||||
editors: {
|
||||
nodes: [
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDFd",
|
||||
rowId: 1,
|
||||
firstName: "Erika",
|
||||
lastName: "Mustermann",
|
||||
role: "CANDYMAT_EDITOR",
|
||||
__typename: "Person",
|
||||
},
|
||||
],
|
||||
__typename: "PeopleConnection",
|
||||
},
|
||||
candidates: {
|
||||
nodes: [
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDJd",
|
||||
rowId: 2,
|
||||
firstName: "Max",
|
||||
lastName: "Mustermann",
|
||||
role: "CANDYMAT_CANDIDATE",
|
||||
__typename: "Person",
|
||||
},
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDNd",
|
||||
rowId: 3,
|
||||
firstName: "Tricia",
|
||||
lastName: "McMillan",
|
||||
role: "CANDYMAT_CANDIDATE",
|
||||
__typename: "Person",
|
||||
},
|
||||
],
|
||||
__typename: "PeopleConnection",
|
||||
},
|
||||
users: {
|
||||
nodes: [
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDRd",
|
||||
rowId: 4,
|
||||
firstName: "Happy",
|
||||
lastName: "User",
|
||||
role: "CANDYMAT_PERSON",
|
||||
__typename: "Person",
|
||||
},
|
||||
],
|
||||
__typename: "PeopleConnection",
|
||||
},
|
||||
};
|
||||
|
||||
export const getPersonsSortedByRoleAllFilledMock: Array<
|
||||
MockedResponse<GetPersonsSortedByRoleResponse>
|
||||
> = [
|
||||
{
|
||||
request: {
|
||||
query: GET_PERSONS_SORTED_BY_ROLE,
|
||||
},
|
||||
result: {
|
||||
data: getPersonsByRoleAllFilledData,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const getPersonsByRoleNoCandidatesData: GetPersonsSortedByRoleResponse =
|
||||
{
|
||||
editors: {
|
||||
nodes: [
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDFd",
|
||||
rowId: 1,
|
||||
firstName: "Erika",
|
||||
lastName: "Mustermann",
|
||||
role: "CANDYMAT_EDITOR",
|
||||
__typename: "Person",
|
||||
},
|
||||
],
|
||||
__typename: "PeopleConnection",
|
||||
},
|
||||
candidates: {
|
||||
nodes: [],
|
||||
__typename: "PeopleConnection",
|
||||
},
|
||||
users: {
|
||||
nodes: [
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDJd",
|
||||
rowId: 2,
|
||||
firstName: "Max",
|
||||
lastName: "Mustermann",
|
||||
role: "CANDYMAT_PERSON",
|
||||
__typename: "Person",
|
||||
},
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDNd",
|
||||
rowId: 3,
|
||||
firstName: "Tricia",
|
||||
lastName: "McMillan",
|
||||
role: "CANDYMAT_PERSON",
|
||||
__typename: "Person",
|
||||
},
|
||||
{
|
||||
id: "WyJwZW9wbGUiLDRd",
|
||||
rowId: 4,
|
||||
firstName: "Happy",
|
||||
lastName: "User",
|
||||
role: "CANDYMAT_PERSON",
|
||||
__typename: "Person",
|
||||
},
|
||||
],
|
||||
__typename: "PeopleConnection",
|
||||
},
|
||||
};
|
||||
|
||||
export const getPersonsSortedByRoleNoCandidatesMock: Array<
|
||||
MockedResponse<GetPersonsSortedByRoleResponse>
|
||||
> = [
|
||||
{
|
||||
request: {
|
||||
query: GET_PERSONS_SORTED_BY_ROLE,
|
||||
},
|
||||
result: {
|
||||
data: getPersonsByRoleNoCandidatesData,
|
||||
},
|
||||
},
|
||||
];
|
|
@ -0,0 +1,57 @@
|
|||
import { gql } from "@apollo/client";
|
||||
import { UppercaseUserRole } from "../../jwt/jwt";
|
||||
|
||||
export const BasicPersonFragment = gql`
|
||||
fragment BasicPersonFragment on Person {
|
||||
id
|
||||
rowId
|
||||
firstName
|
||||
lastName
|
||||
role
|
||||
}
|
||||
`;
|
||||
|
||||
export interface BasicPersonResponse {
|
||||
id: string;
|
||||
rowId: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
role: UppercaseUserRole;
|
||||
__typename: "Person";
|
||||
}
|
||||
|
||||
export const GET_PERSONS_SORTED_BY_ROLE = gql`
|
||||
query AllPeople {
|
||||
editors: allPeople(condition: { role: CANDYMAT_EDITOR }) {
|
||||
nodes {
|
||||
...BasicPersonFragment
|
||||
}
|
||||
}
|
||||
candidates: allPeople(condition: { role: CANDYMAT_CANDIDATE }) {
|
||||
nodes {
|
||||
...BasicPersonFragment
|
||||
}
|
||||
}
|
||||
users: allPeople(condition: { role: CANDYMAT_PERSON }) {
|
||||
nodes {
|
||||
...BasicPersonFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${BasicPersonFragment}
|
||||
`;
|
||||
|
||||
export interface GetPersonsSortedByRoleResponse {
|
||||
editors: {
|
||||
nodes: Array<BasicPersonResponse>;
|
||||
__typename: "PeopleConnection";
|
||||
};
|
||||
candidates: {
|
||||
nodes: Array<BasicPersonResponse>;
|
||||
__typename: "PeopleConnection";
|
||||
};
|
||||
users: {
|
||||
nodes: Array<BasicPersonResponse>;
|
||||
__typename: "PeopleConnection";
|
||||
};
|
||||
}
|
|
@ -40,7 +40,7 @@ const useStyles = makeStyles((theme: Theme) =>
|
|||
);
|
||||
|
||||
interface AccordionQuestionAnswerProps {
|
||||
personRowId: number;
|
||||
loggedInPersonRowId: number;
|
||||
question: QuestionAnswerResponse;
|
||||
}
|
||||
|
||||
|
@ -86,7 +86,7 @@ export default function AccordionQuestionAnswer(
|
|||
</Typography>
|
||||
<Divider />
|
||||
<EditAnswerSection
|
||||
personRowId={props.personRowId}
|
||||
loggedInPersonRowId={props.loggedInPersonRowId}
|
||||
question={props.question}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
|
|
|
@ -36,7 +36,9 @@ interface AccordionWithEditProps {
|
|||
title: string;
|
||||
description: string | null;
|
||||
subTitle?: string | null;
|
||||
|
||||
onEditButtonClick?(): void;
|
||||
|
||||
onDeleteButtonClick?(): void;
|
||||
}
|
||||
|
||||
|
|
|
@ -33,6 +33,22 @@ const useStyles = makeStyles((theme) => ({
|
|||
},
|
||||
}));
|
||||
|
||||
export const getIconForPosition = (
|
||||
position: CandidatePosition,
|
||||
props?: SvgIconProps
|
||||
): JSX.Element => {
|
||||
switch (position) {
|
||||
case CandidatePosition.positive:
|
||||
return <ThumbUpIcon {...props} />;
|
||||
case CandidatePosition.neutral:
|
||||
return <RadioButtonUncheckedIcon {...props} />;
|
||||
case CandidatePosition.negative:
|
||||
return <ThumbDown {...props} />;
|
||||
case CandidatePosition.skipped:
|
||||
return <CloseIcon {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
export function CandidatePositionLegend(): React.ReactElement {
|
||||
const classes = useStyles();
|
||||
|
||||
|
@ -57,19 +73,3 @@ export function CandidatePositionLegend(): React.ReactElement {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getIconForPosition = (
|
||||
position: CandidatePosition,
|
||||
props?: SvgIconProps
|
||||
): JSX.Element => {
|
||||
switch (position) {
|
||||
case CandidatePosition.positive:
|
||||
return <ThumbUpIcon {...props} />;
|
||||
case CandidatePosition.neutral:
|
||||
return <RadioButtonUncheckedIcon {...props} />;
|
||||
case CandidatePosition.negative:
|
||||
return <ThumbDown {...props} />;
|
||||
case CandidatePosition.skipped:
|
||||
return <CloseIcon {...props} />;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -28,9 +28,9 @@ const useStyles = makeStyles((theme) => ({
|
|||
}));
|
||||
|
||||
export default function CategoryList(): React.ReactElement {
|
||||
const categories = useQuery<GetAllCategoriesResponse, null>(
|
||||
GET_ALL_CATEGORIES
|
||||
).data?.allCategories.nodes;
|
||||
const categories =
|
||||
useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data
|
||||
?.allCategories.nodes;
|
||||
const classes = useStyles();
|
||||
|
||||
const handleAddClick = () => {
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
import React from "react";
|
||||
import { IconButton, MenuItem } from "@material-ui/core";
|
||||
import Menu from "@material-ui/core/Menu";
|
||||
import EditIcon from "@material-ui/icons/Edit";
|
||||
import { UPPERCASE_USER_ROLES, UppercaseUserRole } from "../jwt/jwt";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import {
|
||||
CHANGE_ROLE,
|
||||
ChangeRoleResponse,
|
||||
ChangeRoleVariables,
|
||||
} from "../backend/mutations/userRole";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { GET_PERSONS_SORTED_BY_ROLE } from "../backend/queries/person";
|
||||
|
||||
interface ChangeRoleMenuProps {
|
||||
currentRole: UppercaseUserRole;
|
||||
currentUserRowId: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ChangeRoleMenu(
|
||||
props: ChangeRoleMenuProps
|
||||
): React.ReactElement {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const otherRoles = UPPERCASE_USER_ROLES.filter(
|
||||
(role) => role != props.currentRole
|
||||
);
|
||||
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const [changeRole] = useMutation<ChangeRoleResponse, ChangeRoleVariables>(
|
||||
CHANGE_ROLE,
|
||||
{
|
||||
onCompleted() {
|
||||
handleClose();
|
||||
},
|
||||
onError(e) {
|
||||
console.error(e);
|
||||
handleClose();
|
||||
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
refetchQueries: [{ query: GET_PERSONS_SORTED_BY_ROLE }],
|
||||
}
|
||||
);
|
||||
const displayRole = (role: UppercaseUserRole) => {
|
||||
switch (role) {
|
||||
case "CANDYMAT_CANDIDATE":
|
||||
return "zu Kandidat:in machen";
|
||||
case "CANDYMAT_EDITOR":
|
||||
return "zu RedakteurIn machen";
|
||||
case "CANDYMAT_PERSON":
|
||||
return "zu Standard User machen";
|
||||
}
|
||||
};
|
||||
return (
|
||||
<React.Fragment>
|
||||
<IconButton
|
||||
aria-label="role of current user"
|
||||
aria-controls="change-role"
|
||||
aria-haspopup="true"
|
||||
onClick={handleMenu}
|
||||
color="inherit"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="change-role"
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{otherRoles.map((role) => (
|
||||
<MenuItem
|
||||
key={role}
|
||||
onClick={() => {
|
||||
changeRole({
|
||||
variables: {
|
||||
personRowId: props.currentUserRowId,
|
||||
newRole: role,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{displayRole(role)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
|
@ -1,80 +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 { 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 = () => {
|
||||
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>
|
||||
);
|
||||
|
|
|
@ -52,9 +52,9 @@ export default function DialogChangeQuestion(): React.ReactElement {
|
|||
},
|
||||
}
|
||||
);
|
||||
const categories = useQuery<GetAllCategoriesResponse, null>(
|
||||
GET_ALL_CATEGORIES
|
||||
).data?.allCategories.nodes;
|
||||
const categories =
|
||||
useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data
|
||||
?.allCategories.nodes;
|
||||
|
||||
const [editQuestion, { loading: editLoading }] = useMutation<
|
||||
EditQuestionResponse,
|
||||
|
|
|
@ -22,7 +22,7 @@ import ToggleButtonGroupAnswerPosition from "./ToggleButtonGroupAnswerPosition";
|
|||
import EditAnswerText from "./EditAnswerText";
|
||||
|
||||
interface EditAnswerSectionProps {
|
||||
personRowId: number;
|
||||
loggedInPersonRowId: number;
|
||||
question: QuestionAnswerResponse;
|
||||
}
|
||||
|
||||
|
@ -35,7 +35,7 @@ export default function EditAnswerSection(
|
|||
GetAnswerByQuestionAndPersonVariables
|
||||
>(GET_ANSWER_BY_QUESTION_AND_PERSON, {
|
||||
variables: {
|
||||
personRowId: props.personRowId,
|
||||
personRowId: props.loggedInPersonRowId,
|
||||
questionRowId: props.question.rowId,
|
||||
},
|
||||
});
|
||||
|
@ -92,7 +92,7 @@ export default function EditAnswerSection(
|
|||
const savePosition = parsePosition(position);
|
||||
const response = await addAnswer({
|
||||
variables: {
|
||||
personRowId: props.personRowId,
|
||||
personRowId: props.loggedInPersonRowId,
|
||||
questionRowId: props.question.rowId,
|
||||
position: savePosition,
|
||||
text: text,
|
||||
|
@ -104,7 +104,7 @@ export default function EditAnswerSection(
|
|||
id: "somethingIntermediate",
|
||||
position: savePosition,
|
||||
text: text || null,
|
||||
personRowId: props.personRowId,
|
||||
personRowId: props.loggedInPersonRowId,
|
||||
questionRowId: props.question.rowId,
|
||||
__typename: "Answer",
|
||||
},
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
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: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
flexDirection: "column",
|
||||
},
|
||||
}));
|
||||
|
||||
interface CandidateRoutes extends PersonRoutes {
|
||||
question: MenuOption;
|
||||
}
|
||||
|
||||
export const candidateRoutes: CandidateRoutes = {
|
||||
question: {
|
||||
title: "Fragen beantworten",
|
||||
path: "/",
|
||||
icon: <QuestionAnswerIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
interface HomePageCandidateProps {
|
||||
loggedInPersonRowId: number;
|
||||
}
|
||||
|
||||
export function HomePageCandidate(
|
||||
props: HomePageCandidateProps
|
||||
): React.ReactElement {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<Switch>
|
||||
<Route exact path={candidateRoutes.question.path}>
|
||||
<QuestionAnswersList
|
||||
loggedInPersonRowId={props.loggedInPersonRowId}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
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";
|
||||
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";
|
||||
import { UserManagement } from "./UserManagement";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
flexDirection: "column",
|
||||
},
|
||||
}));
|
||||
|
||||
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 />,
|
||||
},
|
||||
};
|
||||
|
||||
interface HomePageEditorProps {
|
||||
loggedInUserRowId: number;
|
||||
}
|
||||
|
||||
export function HomePageEditor(props: HomePageEditorProps): React.ReactElement {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<Switch>
|
||||
<Route exact path={editorRoutes.question.path}>
|
||||
<QuestionList />
|
||||
<CategoryList />
|
||||
</Route>
|
||||
<Route path={editorRoutes.userManagement.path}>
|
||||
<UserManagement loggedInPersonRowId={props.loggedInUserRowId} />
|
||||
</Route>
|
||||
</Switch>
|
||||
<Copyright />
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
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: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
flexDirection: "column",
|
||||
},
|
||||
}));
|
||||
|
||||
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}>
|
||||
<Switch>
|
||||
<Route exact path={userRoutes.home.path}>
|
||||
Sorry, für dich gibt es hier leider nichts zu sehen...
|
||||
</Route>
|
||||
</Switch>
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -1,65 +1,92 @@
|
|||
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() {
|
||||
function renderMainPage(jwt: JwtPayload) {
|
||||
render(
|
||||
<MockedProvider>
|
||||
<MemoryRouter>
|
||||
<SnackbarProvider>
|
||||
<Main />
|
||||
<Main userRole={jwt.role} loggedInUserRowId={jwt.person_row_id} />
|
||||
</SnackbarProvider>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 baseJwt: JwtPayload = {
|
||||
aud: "postgraphile",
|
||||
exp: 0,
|
||||
iat: 0,
|
||||
iss: "postgraphile",
|
||||
role: "candymat_person",
|
||||
person_row_id: 3,
|
||||
};
|
||||
|
||||
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
|
||||
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();
|
||||
test("has a menu with two entries", async () => {
|
||||
renderMainPage(jwt);
|
||||
|
||||
const questionListHeadline = screen.queryByText(/Fragen/);
|
||||
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",
|
||||
person_row_id: 2,
|
||||
};
|
||||
renderMainPage(jwt);
|
||||
|
||||
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", () => {
|
||||
const userToken =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
|
||||
localStorage.setItem("token", userToken);
|
||||
renderMainPage();
|
||||
describe("As a simple user, the main page", () => {
|
||||
test("displays the user's home page.", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,57 +1,89 @@
|
|||
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 { editorRoutes, HomePageEditor } from "./HomePageEditor";
|
||||
import { UserRole } from "../jwt/jwt";
|
||||
import { candidateRoutes, HomePageCandidate } from "./HomePageCandidate";
|
||||
import { HomePageUser, userRoutes } from "./HomePageUser";
|
||||
import { MainMenu, mainMenuOpen, mainMenuWidth, MenuOption } from "./MainMenu";
|
||||
import clsx from "clsx";
|
||||
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 {
|
||||
export interface PersonRoutes {
|
||||
[id: string]: MenuOption;
|
||||
}
|
||||
|
||||
interface MainProps {
|
||||
userRole: UserRole;
|
||||
loggedInUserRowId: 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 loggedInUserRowId={props.loggedInUserRowId} />;
|
||||
case "candymat_candidate":
|
||||
return (
|
||||
<HomePageCandidate loggedInPersonRowId={props.loggedInUserRowId} />
|
||||
);
|
||||
case "candymat_person":
|
||||
return <HomePageUser />;
|
||||
}
|
||||
};
|
||||
|
||||
const getMenuOptions = (): Array<MenuOption> => {
|
||||
switch (props.userRole) {
|
||||
case "candymat_editor":
|
||||
return Object.values(editorRoutes);
|
||||
case "candymat_candidate":
|
||||
return Object.values(candidateRoutes);
|
||||
case "candymat_person":
|
||||
return Object.values(userRoutes);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
|
|
@ -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<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 history = useHistory();
|
||||
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}
|
||||
onClick={() => history.push(option.path)}
|
||||
>
|
||||
<ListItemIcon>{option.icon}</ListItemIcon>
|
||||
<ListItemText primary={option.title} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<QuestionAnswersList personRowId={props.personRowId} />
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<QuestionList />
|
||||
<CategoryList />
|
||||
<Copyright />
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
Sorry, für dich gibt es hier leider nichts zu sehen...
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<Paper className={classes.root}>
|
||||
<Avatar className={classes.avatar} aria-label={fullName}>
|
||||
{getInitials()}
|
||||
</Avatar>
|
||||
<Typography className={classes.title}>{fullName}</Typography>
|
||||
<ChangeRoleMenu
|
||||
currentUserRowId={currentUserRowId}
|
||||
currentRole={userRole}
|
||||
disabled={props.loggedInUserRowId === props.userRowId}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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) => (
|
||||
<AccordionQuestionAnswer
|
||||
key={question.rowId}
|
||||
personRowId={props.personRowId}
|
||||
loggedInPersonRowId={props.loggedInPersonRowId}
|
||||
question={question}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -28,8 +28,9 @@ const useStyles = makeStyles((theme) => ({
|
|||
}));
|
||||
|
||||
export default function QuestionList(): React.ReactElement {
|
||||
const questions = useQuery<GetAllQuestionsResponse, null>(GET_ALL_QUESTIONS)
|
||||
.data?.allQuestions.nodes;
|
||||
const questions =
|
||||
useQuery<GetAllQuestionsResponse, null>(GET_ALL_QUESTIONS).data
|
||||
?.allQuestions.nodes;
|
||||
const classes = useStyles();
|
||||
|
||||
const handleAddButtonClick = () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -8,23 +8,6 @@ export interface SignUpError {
|
|||
passwordInvalid: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 => {
|
||||
|
|
|
@ -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<GetPersonsSortedByRoleResponse>(
|
||||
GET_PERSONS_SORTED_BY_ROLE
|
||||
).data;
|
||||
|
||||
const convertPersonNodeToPersonCard = (
|
||||
person: BasicPersonResponse
|
||||
): JSX.Element => {
|
||||
return (
|
||||
<PersonCard
|
||||
key={person.id}
|
||||
firstName={person.firstName || ""}
|
||||
lastName={person.lastName || ""}
|
||||
userRole={person.role}
|
||||
userRowId={person.rowId}
|
||||
loggedInUserRowId={props.loggedInPersonRowId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Paper className={classes.root}>
|
||||
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
|
||||
Redaktion
|
||||
</Typography>
|
||||
{persons?.editors.nodes.map(convertPersonNodeToPersonCard)}
|
||||
</Paper>
|
||||
<Paper className={classes.root}>
|
||||
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
|
||||
KandidatInnen
|
||||
</Typography>
|
||||
{persons?.candidates.nodes.map(convertPersonNodeToPersonCard)}
|
||||
</Paper>
|
||||
<Paper className={classes.root}>
|
||||
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
|
||||
Andere registrierte Personen
|
||||
</Typography>
|
||||
{persons?.users.nodes.map(convertPersonNodeToPersonCard)}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
<ApolloProvider client={client}>
|
||||
<Router>
|
||||
<SnackbarProvider maxSnack={3}>
|
||||
<App />
|
||||
</SnackbarProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<SnackbarProvider maxSnack={3}>
|
||||
<App />
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
</Router>
|
||||
</ApolloProvider>,
|
||||
document.getElementById("root")
|
||||
|
|
|
@ -28,6 +28,111 @@ import {
|
|||
} from "./test-helper";
|
||||
import QuestionAnswersList from "../components/QuestionAnswerList";
|
||||
|
||||
function renderQuestionAnswerList(additionalMocks?: Array<MockedResponse>) {
|
||||
const initialMocks = [
|
||||
...getAllQuestionAnswersMock,
|
||||
...getAnswerByQuestionAndPersonMock,
|
||||
];
|
||||
const allMocks = additionalMocks
|
||||
? [...initialMocks, ...additionalMocks]
|
||||
: initialMocks;
|
||||
return render(
|
||||
<MockedProvider mocks={allMocks}>
|
||||
<MemoryRouter>
|
||||
<SnackbarProvider>
|
||||
<QuestionAnswersList loggedInPersonRowId={2} />
|
||||
</SnackbarProvider>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const waitForQuestionsToRender = async (): Promise<Array<HTMLElement>> => {
|
||||
const numberOfAnswersInMockQuery = questionAnswersMock.length;
|
||||
let questionAnswerCards: Array<HTMLElement> = [];
|
||||
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<PositionIconButtons> => {
|
||||
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<AccordionAnswerSection | undefined> => {
|
||||
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<MockedResponse>) {
|
||||
const initialMocks = [
|
||||
...getAllQuestionAnswersMock,
|
||||
...getAnswerByQuestionAndPersonMock,
|
||||
];
|
||||
const allMocks = additionalMocks
|
||||
? [...initialMocks, ...additionalMocks]
|
||||
: initialMocks;
|
||||
return render(
|
||||
<MockedProvider mocks={allMocks}>
|
||||
<MemoryRouter>
|
||||
<SnackbarProvider>
|
||||
<QuestionAnswersList personRowId={2} />
|
||||
</SnackbarProvider>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const waitForQuestionsToRender = async (): Promise<Array<HTMLElement>> => {
|
||||
const numberOfAnswersInMockQuery = questionAnswersMock.length;
|
||||
let questionAnswerCards: Array<HTMLElement> = [];
|
||||
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<PositionIconButtons> => {
|
||||
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<AccordionAnswerSection | undefined> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -21,6 +21,40 @@ import {
|
|||
queryAllEditIconButtons,
|
||||
} from "./test-helper";
|
||||
|
||||
function renderQuestionList(additionalMocks?: Array<MockedResponse>) {
|
||||
const initialMocks = [
|
||||
...getAllQuestionsMock,
|
||||
...getQuestionByIdMock,
|
||||
...getAllCategoriesMock,
|
||||
];
|
||||
const allMocks = additionalMocks
|
||||
? [...initialMocks, ...additionalMocks]
|
||||
: initialMocks;
|
||||
return render(
|
||||
<MockedProvider mocks={allMocks}>
|
||||
<MemoryRouter>
|
||||
<SnackbarProvider>
|
||||
<QuestionList />
|
||||
</SnackbarProvider>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const waitForInitialQuestionsToRender = async (): Promise<
|
||||
Array<HTMLElement>
|
||||
> => {
|
||||
const numberOfQuestionsInMockQuery = questionNodesMock.length;
|
||||
let questionCards: Array<HTMLElement> = [];
|
||||
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<MockedResponse>) {
|
||||
const initialMocks = [
|
||||
...getAllQuestionsMock,
|
||||
...getQuestionByIdMock,
|
||||
...getAllCategoriesMock,
|
||||
];
|
||||
const allMocks = additionalMocks
|
||||
? [...initialMocks, ...additionalMocks]
|
||||
: initialMocks;
|
||||
return render(
|
||||
<MockedProvider mocks={allMocks}>
|
||||
<MemoryRouter>
|
||||
<SnackbarProvider>
|
||||
<QuestionList />
|
||||
</SnackbarProvider>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const waitForInitialQuestionsToRender = async (): Promise<
|
||||
Array<HTMLElement>
|
||||
> => {
|
||||
const numberOfQuestionsInMockQuery = questionNodesMock.length;
|
||||
let questionCards: Array<HTMLElement> = [];
|
||||
await waitFor(() => {
|
||||
questionCards = screen.queryAllByRole("button", {
|
||||
name: /Question [1-3]\?/,
|
||||
});
|
||||
expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
|
||||
});
|
||||
return questionCards;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,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<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
|
||||
|
|
|
@ -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<MockedResponse>) {
|
||||
return render(
|
||||
<MockedProvider mocks={mocks}>
|
||||
<MemoryRouter>
|
||||
<SnackbarProvider>
|
||||
<UserManagement />
|
||||
</SnackbarProvider>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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<void> => {
|
||||
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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue