Merge pull request 'feature/#14' (#23) from feature/#14 into develop

Reviewed-on: Netzbegruenung/candymat#23
This commit is contained in:
Christoph Lienhard 2021-01-30 21:49:24 +01:00
commit c12aadef04
69 changed files with 4985 additions and 2221 deletions

14
backend/deep_reset_db.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/bash
docker-compose stop postgres
CONTAINER=$(docker image rm candymat-postgres:11.5 2> >(grep -P '[a-f0-9]{12}' -o) | head -1)
echo "Going to remove container: $CONTAINER"
docker container rm $CONTAINER
docker image rm candymat-postgres:11.5
echo "Deleting db-data docker volumes ..."
VOLUMES=$(docker volume ls -q | grep "db-data")
for volume in ${VOLUMES[@]}; do
echo "Deleting volume '$volume'"
docker volume rm $volume
done;
docker-compose up --build postgres

View file

@ -30,7 +30,7 @@ create table candymat_data.answer
( (
question_row_id integer REFERENCES candymat_data.question (row_id) ON UPDATE CASCADE ON DELETE CASCADE, question_row_id integer REFERENCES candymat_data.question (row_id) ON UPDATE CASCADE ON DELETE CASCADE,
person_row_id integer REFERENCES candymat_data.person (row_id) ON UPDATE CASCADE ON DELETE CASCADE, person_row_id integer REFERENCES candymat_data.person (row_id) ON UPDATE CASCADE ON DELETE CASCADE,
position integer NOT NULL, position integer NOT NULL check (position between 0 and 3),
text character varying(15000), text character varying(15000),
created_at timestamp default now(), created_at timestamp default now(),
primary key (question_row_id, person_row_id) primary key (question_row_id, person_row_id)

@ -1 +1 @@
Subproject commit f239bec4ffb7327ed88679239c7c918825811040 Subproject commit d414b95c1c664adcd5149aee8eac4436b40d7dfb

View file

@ -0,0 +1,19 @@
env:
browser: true
extends:
- 'eslint:recommended'
- 'plugin:react/recommended'
- 'plugin:@typescript-eslint/recommended'
parser: '@typescript-eslint/parser'
parserOptions:
ecmaFeatures:
jsx: true
ecmaVersion: 12
sourceType: module
plugins:
- react
- '@typescript-eslint'
rules: {}
settings:
react:
version: detect

View file

@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

View file

@ -0,0 +1 @@
{}

View file

@ -9,18 +9,18 @@ The app is written in typescript and react and uses apollo to query the backend
### Setup ### Setup
* Install `npm` - Install `npm`
* In this directory run `npm ci` to install all dependencies according to the package.json and package-lock.json. - In this directory run `npm ci` to install all dependencies according to the package.json and package-lock.json.
### Develop locally ### Develop locally
* In the parent directory run - In the parent directory run
```shell script ```shell script
docker-compose up docker-compose up
``` ```
which will start the whole setup including this app in a dockerfile. 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. However, rebuilding and restarting this image can be cumbersome and is not necessary in the development setup.
* Instead run - Instead run
```shell script ```shell script
npm start npm start
``` ```

File diff suppressed because it is too large Load diff

View file

@ -24,8 +24,14 @@
"@types/react": "^16.9.46", "@types/react": "^16.9.46",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5", "@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-plugin-react": "^7.22.0",
"husky": "^4.3.6", "husky": "^4.3.6",
"jest-environment-jsdom-sixteen": "^1.0.3" "jest-environment-jsdom-sixteen": "^1.0.3",
"lint-staged": "^10.5.3",
"prettier": "2.2.1"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@ -38,9 +44,15 @@
}, },
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "npm test" "pre-commit": "lint-staged && npm test"
} }
}, },
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint",
"prettier --write"
]
},
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",

View file

@ -8,7 +8,6 @@
<meta <meta
name="description" name="description"
content="App zum Erstellen von Fragen für den Candymat" content="App zum Erstellen von Fragen für den Candymat"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- <!--

View file

@ -1,25 +1,29 @@
import './App.css'; import "./App.css";
import React from "react"; import React from "react";
import Main from "./components/Main"; import Main from "./components/Main";
import {Redirect, Route, Switch} from "react-router-dom"; import { Redirect, Route, RouteProps, Switch } from "react-router-dom";
import SignIn from "./components/SignIn"; import SignIn from "./components/SignIn";
import SignUp from "./components/SignUp"; import SignUp from "./components/SignUp";
function App(): React.ReactElement {
function App() {
return ( return (
<Switch> <Switch>
<PrivateRoute exact path={"/"}><Main /></PrivateRoute> <PrivateRoute exact path={"/"}>
<NotLoggedInOnlyRoute path={"/login"}><SignIn /></NotLoggedInOnlyRoute> <Main />
<NotLoggedInOnlyRoute path={"/signup"}><SignUp /></NotLoggedInOnlyRoute> </PrivateRoute>
<NotLoggedInOnlyRoute path={"/login"}>
<SignIn />
</NotLoggedInOnlyRoute>
<NotLoggedInOnlyRoute path={"/signup"}>
<SignUp />
</NotLoggedInOnlyRoute>
</Switch> </Switch>
) );
} }
export const isLoggedIn = () => !!localStorage.getItem("token") export const isLoggedIn = (): boolean => !!localStorage.getItem("token");
// @ts-ignore function PrivateRoute({ children, ...rest }: RouteProps) {
function PrivateRoute({ children, ...rest }) {
return ( return (
<Route <Route
{...rest} {...rest}
@ -30,7 +34,7 @@ function PrivateRoute({ children, ...rest }) {
<Redirect <Redirect
to={{ to={{
pathname: "/login", pathname: "/login",
state: { from: location } state: { from: location },
}} }}
/> />
) )
@ -39,12 +43,11 @@ function PrivateRoute({ children, ...rest }) {
); );
} }
// @ts-ignore function NotLoggedInOnlyRoute({ children, ...rest }: RouteProps) {
function NotLoggedInOnlyRoute({ children, ...rest }) {
return ( return (
<Route <Route
{...rest} {...rest}
render={({ location }) => render={() =>
!isLoggedIn() ? ( !isLoggedIn() ? (
children children
) : ( ) : (

View file

@ -1,20 +1,21 @@
import {ApolloClient, createHttpLink, InMemoryCache} from "@apollo/client"; import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import {setContext} from "@apollo/client/link/context"; import { setContext } from "@apollo/client/link/context";
import {getRawJsonWebToken} from "../jwt/jwt"; import { getRawJsonWebToken } from "../jwt/jwt";
const httpLink = createHttpLink({ const httpLink = createHttpLink({
uri: 'http://localhost:5433/graphql', uri: "http://localhost:5433/graphql",
}); });
const authLink = setContext((_, { headers }) => { const authLink = setContext((_, { headers }) => {
const token = getRawJsonWebToken(); const token = getRawJsonWebToken();
return token ? { return token
headers: { ? {
...headers, headers: {
authorization: `Bearer ${token}`, ...headers,
} authorization: `Bearer ${token}`,
} : headers },
}
: headers;
}); });
export const client = new ApolloClient({ export const client = new ApolloClient({

View file

@ -0,0 +1,84 @@
import { MockedResponse } from "@apollo/client/testing";
import {
ADD_ANSWER,
AddAnswerResponse,
AddAnswerVariables,
EDIT_ANSWER,
EditAnswerPayload,
EditAnswerResponse,
EditAnswerVariables,
} from "./answer";
import { answersMock } from "../queries/answer.mock";
import { CandidatePosition } from "../../components/CandidatePositionLegend";
import { FullAnswerResponse } from "../queries/answer";
const editAnswerVariables: EditAnswerVariables = {
id: "a22",
text: "New answer",
};
const getEditedAnswerMock = (): EditAnswerPayload | null => {
const originalAnswer = answersMock.find(
(a) => a.id === editAnswerVariables.id
);
return originalAnswer
? {
answer: {
...originalAnswer,
text:
editAnswerVariables.text === undefined
? originalAnswer.text
: editAnswerVariables.text,
position:
editAnswerVariables.position === undefined
? originalAnswer.position
: editAnswerVariables.position,
},
__typename: "UpdateAnswerPayload",
}
: null;
};
export const editAnswerMock: Array<MockedResponse<EditAnswerResponse>> = [
{
request: {
query: EDIT_ANSWER,
variables: editAnswerVariables,
},
result: {
data: {
updateAnswer: getEditedAnswerMock(),
},
},
},
];
const addAnswerVariables: AddAnswerVariables = {
position: CandidatePosition.positive,
questionRowId: 3,
personRowId: 2,
};
const addedAnswerMock: FullAnswerResponse = {
id: "newA",
...addAnswerVariables,
text: addAnswerVariables.text !== undefined ? addAnswerVariables.text : null,
__typename: "Answer",
};
export const addAnswerMock: Array<MockedResponse<AddAnswerResponse>> = [
{
request: {
query: ADD_ANSWER,
variables: addAnswerVariables,
},
result: {
data: {
createAnswer: {
answer: addedAnswerMock,
__typename: "CreateAnswerPayload",
},
},
},
},
];

View file

@ -0,0 +1,163 @@
import {
ApolloCache,
FetchResult,
gql,
Reference,
StoreObject,
} from "@apollo/client";
import {
FullAnswerFragment,
FullAnswerResponse,
QuestionAnswerResponse,
} from "../queries/answer";
import { CandidatePosition } from "../../components/CandidatePositionLegend";
export const EDIT_ANSWER = gql`
mutation UpdateAnswer($id: ID!, $position: Int, $text: String) {
updateAnswer(
input: { id: $id, answerPatch: { position: $position, text: $text } }
) {
answer {
...FullAnswerFragment
}
}
}
${FullAnswerFragment}
`;
export interface EditAnswerResponse {
updateAnswer: EditAnswerPayload | null;
}
export interface EditAnswerPayload {
answer: FullAnswerResponse;
__typename: "UpdateAnswerPayload";
}
export interface EditAnswerVariables {
id: string;
position?: CandidatePosition;
text?: string | null;
}
export const ADD_ANSWER = gql`
mutation AddAnswer(
$questionRowId: Int!
$personRowId: Int!
$position: Int!
$text: String
) {
createAnswer(
input: {
answer: {
questionRowId: $questionRowId
personRowId: $personRowId
position: $position
text: $text
}
}
) {
answer {
...FullAnswerFragment
}
}
}
${FullAnswerFragment}
`;
export interface AddAnswerResponse {
createAnswer: AddAnswerPayload | null;
}
export interface AddAnswerPayload {
answer: FullAnswerResponse;
__typename: "CreateAnswerPayload";
}
export interface AddAnswerVariables {
questionRowId: number;
personRowId: number;
position: CandidatePosition;
text?: string | null;
}
const matchesStoreFieldName = (
storeFieldName: string,
personRowId: number,
questionRowId: number
): boolean => {
const fullName = `answerByQuestionRowIdAndPersonRowId({"personRowId":${personRowId},"questionRowId":${questionRowId}})`;
return fullName === storeFieldName;
};
interface NodesCacheRefs {
nodes: Array<Reference | StoreObject>;
}
const addAnswerToQuestion = (
cache: ApolloCache<AddAnswerResponse>,
question: QuestionAnswerResponse,
newAnswerRef: Reference
) => {
cache.modify({
id: cache.identify({ ...question }),
fields: {
answersByQuestionRowId: (
answerRefs: NodesCacheRefs = { nodes: [] }
): NodesCacheRefs => {
return { nodes: [...answerRefs.nodes, newAnswerRef] };
},
},
});
};
const addAnswerToRootField = (
cache: ApolloCache<AddAnswerResponse>,
newAnswerRef: Reference,
personRowId: number,
questionRowId: number
) => {
cache.modify({
fields: {
answerByQuestionRowIdAndPersonRowId: (
answerRefs: Reference | StoreObject | null,
{ storeFieldName }
) => {
if (matchesStoreFieldName(storeFieldName, personRowId, questionRowId)) {
return newAnswerRef;
}
},
},
});
};
const writeAnswerToCache = (
cache: ApolloCache<AddAnswerResponse>,
answer: FullAnswerResponse
): Reference | undefined => {
return cache.writeFragment<FullAnswerResponse>({
data: answer,
fragment: FullAnswerFragment,
fragmentName: "FullAnswerFragment",
});
};
export const updateCacheAfterAddingAnswer = (
cache: ApolloCache<AddAnswerResponse>,
{ data }: FetchResult<AddAnswerResponse>,
question: QuestionAnswerResponse
): void => {
const answer = data?.createAnswer?.answer;
if (answer) {
const newAnswerRef = writeAnswerToCache(cache, answer);
if (newAnswerRef) {
addAnswerToQuestion(cache, question, newAnswerRef);
addAnswerToRootField(
cache,
newAnswerRef,
answer.personRowId,
answer.questionRowId
);
}
}
};

View file

@ -1,32 +1,47 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
ADD_CATEGORY, AddCategoryResponse, ADD_CATEGORY,
AddCategoryVariables, DELETE_CATEGORY, DeleteCategoryPayload, DeleteCategoryResponse, DeleteCategoryVariables, AddCategoryResponse,
EDIT_CATEGORY, EditCategoryPayload, AddCategoryVariables,
DELETE_CATEGORY,
DeleteCategoryPayload,
DeleteCategoryResponse,
DeleteCategoryVariables,
EDIT_CATEGORY,
EditCategoryPayload,
EditCategoryResponse, EditCategoryResponse,
EditCategoryVariables EditCategoryVariables,
} from "./category"; } from "./category";
import {BasicCategoryResponse} from "../queries/category"; import { BasicCategoryResponse } from "../queries/category";
import {categoryNodesMock} from "../queries/category.mock"; import { categoryNodesMock } from "../queries/category.mock";
const editCategoryVariables: EditCategoryVariables = { const editCategoryVariables: EditCategoryVariables = {
id: 'c1', id: "c1",
title: 'New title for Category 1', title: "New title for Category 1",
description: 'Further information for C1', description: "Further information for C1",
}; };
const getEditedCategoryMock = (): EditCategoryPayload | null => { const getEditedCategoryMock = (): EditCategoryPayload | null => {
const originalCategory = categoryNodesMock.find(c => c.id === editCategoryVariables.id) const originalCategory = categoryNodesMock.find(
return originalCategory ? { (c) => c.id === editCategoryVariables.id
category: { );
...originalCategory, return originalCategory
title: editCategoryVariables.title === undefined ? originalCategory.title : editCategoryVariables.title, ? {
description: editCategoryVariables.description === undefined ? originalCategory.description : null, category: {
}, ...originalCategory,
__typename: "UpdateCategoryPayload", title:
} : null editCategoryVariables.title === undefined
} ? originalCategory.title
: editCategoryVariables.title,
description:
editCategoryVariables.description === undefined
? originalCategory.description
: null,
},
__typename: "UpdateCategoryPayload",
}
: null;
};
export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [ export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [
{ {
@ -37,13 +52,13 @@ export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [
result: { result: {
data: { data: {
updateCategory: getEditedCategoryMock(), updateCategory: getEditedCategoryMock(),
} },
}, },
}, },
] ];
const addCategoryVariables: AddCategoryVariables = { const addCategoryVariables: AddCategoryVariables = {
title: 'New category', title: "New category",
description: "", description: "",
}; };
@ -52,8 +67,8 @@ const addedCategoryMock: BasicCategoryResponse = {
rowId: 3, rowId: 3,
title: addCategoryVariables.title as string, title: addCategoryVariables.title as string,
description: addCategoryVariables.description as string, description: addCategoryVariables.description as string,
__typename: "Category" __typename: "Category",
} };
export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [ export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [
{ {
@ -66,25 +81,31 @@ export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [
createCategory: { createCategory: {
category: addedCategoryMock, category: addedCategoryMock,
__typename: "CreateCategoryPayload", __typename: "CreateCategoryPayload",
} },
} },
}, },
}, },
] ];
const deleteCategoryVariables: DeleteCategoryVariables = { const deleteCategoryVariables: DeleteCategoryVariables = {
id: 'c2' id: "c2",
}; };
const getDeletedCategoryMock = (): DeleteCategoryPayload | null => { const getDeletedCategoryMock = (): DeleteCategoryPayload | null => {
const categoryToBeDeleted = categoryNodesMock.find(q => q.id === deleteCategoryVariables.id) const categoryToBeDeleted = categoryNodesMock.find(
return categoryToBeDeleted ? { (q) => q.id === deleteCategoryVariables.id
category: categoryToBeDeleted, );
__typename: "DeleteCategoryPayload", return categoryToBeDeleted
} : null ? {
} category: categoryToBeDeleted,
__typename: "DeleteCategoryPayload",
}
: null;
};
export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> = [ export const deleteCategoryMock: Array<
MockedResponse<DeleteCategoryResponse>
> = [
{ {
request: { request: {
query: DELETE_CATEGORY, query: DELETE_CATEGORY,
@ -93,8 +114,7 @@ export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> =
result: { result: {
data: { data: {
deleteCategory: getDeletedCategoryMock(), deleteCategory: getDeletedCategoryMock(),
} },
}, },
}, },
] ];

View file

@ -1,55 +1,65 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
import {BasicCategoryFragment, BasicCategoryResponse} from "../queries/category"; import {
BasicCategoryFragment,
BasicCategoryResponse,
} from "../queries/category";
export const EDIT_CATEGORY = gql` export const EDIT_CATEGORY = gql`
mutation UpdateCategory($id: ID!, $title: String, $description: String) { mutation UpdateCategory($id: ID!, $title: String, $description: String) {
updateCategory(input: {id: $id, categoryPatch: {description: $description, title: $title}}) { updateCategory(
input: {
id: $id
categoryPatch: { description: $description, title: $title }
}
) {
category { category {
...BasicCategoryFragment ...BasicCategoryFragment
} }
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface EditCategoryResponse { export interface EditCategoryResponse {
updateCategory: EditCategoryPayload | null updateCategory: EditCategoryPayload | null;
} }
export interface EditCategoryPayload { export interface EditCategoryPayload {
category: BasicCategoryResponse, category: BasicCategoryResponse;
__typename: "UpdateCategoryPayload", __typename: "UpdateCategoryPayload";
} }
export interface EditCategoryVariables { export interface EditCategoryVariables {
id: string, id: string;
title?: string, title?: string;
description?: string | null, description?: string | null;
} }
export const ADD_CATEGORY = gql` export const ADD_CATEGORY = gql`
mutation AddCategory($title: String!, $description: String) { mutation AddCategory($title: String!, $description: String) {
createCategory(input: {category: {title: $title, description: $description}}) { createCategory(
input: { category: { title: $title, description: $description } }
) {
category { category {
...BasicCategoryFragment ...BasicCategoryFragment
} }
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface AddCategoryResponse { export interface AddCategoryResponse {
createCategory: AddCategoryPayload | null, createCategory: AddCategoryPayload | null;
} }
export interface AddCategoryPayload { export interface AddCategoryPayload {
category: BasicCategoryResponse, category: BasicCategoryResponse;
__typename: "CreateCategoryPayload", __typename: "CreateCategoryPayload";
} }
export interface AddCategoryVariables { export interface AddCategoryVariables {
title: string, title: string;
description?: string | null, description?: string | null;
} }
export const DELETE_CATEGORY = gql` export const DELETE_CATEGORY = gql`
@ -61,17 +71,17 @@ export const DELETE_CATEGORY = gql`
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface DeleteCategoryResponse { export interface DeleteCategoryResponse {
deleteCategory: DeleteCategoryPayload | null deleteCategory: DeleteCategoryPayload | null;
} }
export interface DeleteCategoryPayload { export interface DeleteCategoryPayload {
category: BasicCategoryResponse, category: BasicCategoryResponse;
__typename: "DeleteCategoryPayload", __typename: "DeleteCategoryPayload";
} }
export interface DeleteCategoryVariables { export interface DeleteCategoryVariables {
id: string, id: string;
} }

View file

@ -1,5 +1,5 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import {LOGIN, LoginResponse} from "./login"; import { LOGIN, LoginResponse } from "./login";
export const loginMock: Array<MockedResponse<LoginResponse>> = [ export const loginMock: Array<MockedResponse<LoginResponse>> = [
{ {
@ -8,14 +8,14 @@ export const loginMock: Array<MockedResponse<LoginResponse>> = [
variables: { variables: {
email: "test@email.com", email: "test@email.com",
password: "password", password: "password",
} },
}, },
result: { result: {
data: { data: {
authenticate: { authenticate: {
jwtToken: "123" jwtToken: "123",
} },
} },
}, },
}, },
{ {
@ -24,14 +24,14 @@ export const loginMock: Array<MockedResponse<LoginResponse>> = [
variables: { variables: {
email: "test@email.com", email: "test@email.com",
password: "wrong-password", password: "wrong-password",
} },
}, },
result: { result: {
data: { data: {
authenticate: { authenticate: {
jwtToken: undefined jwtToken: undefined,
} },
} },
}, },
} },
] ];

View file

@ -1,19 +1,20 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
export const LOGIN = gql` export const LOGIN = gql`
mutation Login($email: String!, $password: String!) { mutation Login($email: String!, $password: String!) {
authenticate(input: {email: $email, password: $password}) { authenticate(input: { email: $email, password: $password }) {
jwtToken jwtToken
} }
}` }
`;
export interface LoginVariables { export interface LoginVariables {
email: string, email: string;
password: string password: string;
} }
export interface LoginResponse { export interface LoginResponse {
authenticate: { authenticate: {
jwtToken?: string jwtToken?: string;
} };
} }

View file

@ -1,37 +1,55 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
ADD_QUESTION, AddQuestionResponse, ADD_QUESTION,
AddQuestionVariables, DELETE_QUESTION, DeleteQuestionPayload, DeleteQuestionResponse, DeleteQuestionVariables, AddQuestionResponse,
EDIT_QUESTION, EditQuestionPayload, AddQuestionVariables,
DELETE_QUESTION,
DeleteQuestionPayload,
DeleteQuestionResponse,
DeleteQuestionVariables,
EDIT_QUESTION,
EditQuestionPayload,
EditQuestionResponse, EditQuestionResponse,
EditQuestionVariables EditQuestionVariables,
} from "./question"; } from "./question";
import {BasicQuestionResponse} from "../queries/question"; import { BasicQuestionResponse } from "../queries/question";
import {questionNodesMock} from "../queries/question.mock"; import { questionNodesMock } from "../queries/question.mock";
import {categoryNodesMock} from "../queries/category.mock"; import { categoryNodesMock } from "../queries/category.mock";
const editQuestionVariables: EditQuestionVariables = { const editQuestionVariables: EditQuestionVariables = {
id: 'q1', id: "q1",
title: 'New title for Question 1?', title: "New title for Question 1?",
description: 'Further information for Q1', description: "Further information for Q1",
categoryRowId: 1, categoryRowId: 1,
}; };
const getEditedQuestionMock = (): EditQuestionPayload | null => { const getEditedQuestionMock = (): EditQuestionPayload | null => {
const originalQuestion = questionNodesMock.find(q => q.id === editQuestionVariables.id) const originalQuestion = questionNodesMock.find(
return originalQuestion ? { (q) => q.id === editQuestionVariables.id
question: { );
...originalQuestion, return originalQuestion
title: editQuestionVariables.title === undefined ? originalQuestion.title : editQuestionVariables.title, ? {
description: editQuestionVariables.description === undefined ? originalQuestion.description : null, question: {
categoryByCategoryRowId: editQuestionVariables.categoryRowId === undefined ...originalQuestion,
? originalQuestion.categoryByCategoryRowId title:
: categoryNodesMock.find(c => c.rowId === editQuestionVariables.categoryRowId) || null, editQuestionVariables.title === undefined
}, ? originalQuestion.title
__typename: "UpdateQuestionPayload", : editQuestionVariables.title,
} : null description:
} editQuestionVariables.description === undefined
? originalQuestion.description
: null,
categoryByCategoryRowId:
editQuestionVariables.categoryRowId === undefined
? originalQuestion.categoryByCategoryRowId
: categoryNodesMock.find(
(c) => c.rowId === editQuestionVariables.categoryRowId
) || null,
},
__typename: "UpdateQuestionPayload",
}
: null;
};
export const editQuestionMock: Array<MockedResponse<EditQuestionResponse>> = [ export const editQuestionMock: Array<MockedResponse<EditQuestionResponse>> = [
{ {
@ -42,24 +60,28 @@ export const editQuestionMock: Array<MockedResponse<EditQuestionResponse>> = [
result: { result: {
data: { data: {
updateQuestion: getEditedQuestionMock(), updateQuestion: getEditedQuestionMock(),
} },
}, },
}, },
] ];
const addQuestionVariables: AddQuestionVariables = { const addQuestionVariables: AddQuestionVariables = {
title: 'New question?', title: "New question?",
description: "", description: "",
categoryRowId: null, categoryRowId: null,
}; };
const addedQuestionMock: BasicQuestionResponse = { const addedQuestionMock: BasicQuestionResponse = {
id: `newQ`, id: `newQ`,
rowId: 4,
title: addQuestionVariables.title as string, title: addQuestionVariables.title as string,
description: addQuestionVariables.description as string, description: addQuestionVariables.description as string,
categoryByCategoryRowId: categoryNodesMock.find(c => c.rowId === editQuestionVariables.categoryRowId) || null, categoryByCategoryRowId:
__typename: "Question" categoryNodesMock.find(
} (c) => c.rowId === editQuestionVariables.categoryRowId
) || null,
__typename: "Question",
};
export const addQuestionMock: Array<MockedResponse<AddQuestionResponse>> = [ export const addQuestionMock: Array<MockedResponse<AddQuestionResponse>> = [
{ {
@ -72,25 +94,31 @@ export const addQuestionMock: Array<MockedResponse<AddQuestionResponse>> = [
createQuestion: { createQuestion: {
question: addedQuestionMock, question: addedQuestionMock,
__typename: "CreateQuestionPayload", __typename: "CreateQuestionPayload",
} },
} },
}, },
}, },
] ];
const deleteQuestionVariables: DeleteQuestionVariables = { const deleteQuestionVariables: DeleteQuestionVariables = {
id: 'q2' id: "q2",
}; };
const getDeletedQuestionMock = (): DeleteQuestionPayload | null => { const getDeletedQuestionMock = (): DeleteQuestionPayload | null => {
const questionToBeDeleted = questionNodesMock.find(q => q.id === deleteQuestionVariables.id) const questionToBeDeleted = questionNodesMock.find(
return questionToBeDeleted ? { (q) => q.id === deleteQuestionVariables.id
question: questionToBeDeleted, );
__typename: "DeleteQuestionPayload", return questionToBeDeleted
} : null ? {
} question: questionToBeDeleted,
__typename: "DeleteQuestionPayload",
}
: null;
};
export const deleteQuestionMock: Array<MockedResponse<DeleteQuestionResponse>> = [ export const deleteQuestionMock: Array<
MockedResponse<DeleteQuestionResponse>
> = [
{ {
request: { request: {
query: DELETE_QUESTION, query: DELETE_QUESTION,
@ -99,8 +127,7 @@ export const deleteQuestionMock: Array<MockedResponse<DeleteQuestionResponse>> =
result: { result: {
data: { data: {
deleteQuestion: getDeletedQuestionMock(), deleteQuestion: getDeletedQuestionMock(),
} },
}, },
}, },
] ];

View file

@ -1,57 +1,86 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
import {BasicQuestionFragment, BasicQuestionResponse} from "../queries/question"; import {
BasicQuestionFragment,
BasicQuestionResponse,
} from "../queries/question";
export const EDIT_QUESTION = gql` export const EDIT_QUESTION = gql`
mutation UpdateQuestion($id: ID!, $title: String, $description: String, $categoryRowId: Int) { mutation UpdateQuestion(
updateQuestion(input: {id: $id, questionPatch: {categoryRowId: $categoryRowId, description: $description, title: $title}}) { $id: ID!
$title: String
$description: String
$categoryRowId: Int
) {
updateQuestion(
input: {
id: $id
questionPatch: {
categoryRowId: $categoryRowId
description: $description
title: $title
}
}
) {
question { question {
...BasicQuestionFragment ...BasicQuestionFragment
} }
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface EditQuestionResponse { export interface EditQuestionResponse {
updateQuestion: EditQuestionPayload | null, updateQuestion: EditQuestionPayload | null;
} }
export interface EditQuestionPayload { export interface EditQuestionPayload {
question: BasicQuestionResponse, question: BasicQuestionResponse;
__typename: "UpdateQuestionPayload", __typename: "UpdateQuestionPayload";
} }
export interface EditQuestionVariables { export interface EditQuestionVariables {
id: string, id: string;
title?: string, title?: string;
description?: string | null, description?: string | null;
categoryRowId?: number | null, categoryRowId?: number | null;
} }
export const ADD_QUESTION = gql` export const ADD_QUESTION = gql`
mutation AddQuestion($title: String!, $description: String, $categoryRowId: Int) { mutation AddQuestion(
createQuestion(input: {question: {title: $title, categoryRowId: $categoryRowId, description: $description}}) { $title: String!
$description: String
$categoryRowId: Int
) {
createQuestion(
input: {
question: {
title: $title
categoryRowId: $categoryRowId
description: $description
}
}
) {
question { question {
...BasicQuestionFragment ...BasicQuestionFragment
} }
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface AddQuestionResponse { export interface AddQuestionResponse {
createQuestion: AddQuestionPayload | null createQuestion: AddQuestionPayload | null;
} }
export interface AddQuestionPayload { export interface AddQuestionPayload {
question: BasicQuestionResponse, question: BasicQuestionResponse;
__typename: "CreateQuestionPayload", __typename: "CreateQuestionPayload";
} }
export interface AddQuestionVariables { export interface AddQuestionVariables {
title: string, title: string;
description?: string | null, description?: string | null;
categoryRowId?: number | null categoryRowId?: number | null;
} }
export const DELETE_QUESTION = gql` export const DELETE_QUESTION = gql`
@ -63,19 +92,17 @@ export const DELETE_QUESTION = gql`
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface DeleteQuestionResponse { export interface DeleteQuestionResponse {
deleteQuestion: DeleteQuestionPayload | null, deleteQuestion: DeleteQuestionPayload | null;
} }
export interface DeleteQuestionPayload { export interface DeleteQuestionPayload {
question: BasicQuestionResponse, question: BasicQuestionResponse;
__typename: "DeleteQuestionPayload", __typename: "DeleteQuestionPayload";
} }
export interface DeleteQuestionVariables { export interface DeleteQuestionVariables {
id: string, id: string;
} }

View file

@ -1,26 +1,38 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
export const SIGN_UP = gql` export const SIGN_UP = gql`
mutation CreateAccount($firstName: String!, $lastName: String!, $email: String!, $password: String!) { mutation CreateAccount(
registerPerson(input: {firstName: $firstName, lastName: $lastName, email: $email, password: $password}) { $firstName: String!
person { $lastName: String!
id $email: String!
} $password: String!
} ) {
registerPerson(
input: {
firstName: $firstName
lastName: $lastName
email: $email
password: $password
}
) {
person {
id
}
} }
` }
`;
export interface SignUpVariables { export interface SignUpVariables {
firstName: string, firstName: string;
lastName: string, lastName: string;
email: string, email: string;
password: string, password: string;
} }
export interface SignUpResponse { export interface SignUpResponse {
registerPerson: { registerPerson: {
person: { person: {
id: string id: string;
} };
} };
} }

View file

@ -0,0 +1,133 @@
import { MockedResponse } from "@apollo/client/testing";
import {
AnswerPositionResponse,
FullAnswerResponse,
GET_ALL_QUESTION_ANSWERS,
GET_ANSWER_BY_QUESTION_AND_PERSON,
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables,
GetAnswerByQuestionAndPersonResponse,
GetAnswerByQuestionAndPersonVariables,
QuestionAnswerResponse,
} from "./answer";
import { CandidatePosition } from "../../components/CandidatePositionLegend";
export const answersMock: Array<FullAnswerResponse> = [
{
id: "a12",
text: null,
position: CandidatePosition.neutral,
questionRowId: 1,
personRowId: 2,
__typename: "Answer",
},
{
id: "a22",
text: "Answer 2",
position: CandidatePosition.positive,
questionRowId: 2,
personRowId: 2,
__typename: "Answer",
},
];
const getAnswersForQuestionRowId = (
questionRowId: number
): Array<FullAnswerResponse> => {
return answersMock.filter((answ) => answ.questionRowId === questionRowId);
};
const getAnswersPositionForQuestionRowId = (
questionRowId: number
): Array<AnswerPositionResponse> => {
return getAnswersForQuestionRowId(questionRowId).map((answer) => ({
__typename: answer.__typename,
id: answer.id,
position: answer.position,
}));
};
export const questionAnswersMock: Array<QuestionAnswerResponse> = [
{
id: "q1",
rowId: 1,
title: "Question 1?",
description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
rowId: 1,
title: "Category 1",
__typename: "Category",
},
answersByQuestionRowId: {
nodes: getAnswersPositionForQuestionRowId(1),
__typename: "AnswersConnection",
},
__typename: "Question",
},
{
id: "q2",
rowId: 2,
title: "Question 2?",
description: "Further information for Q2",
categoryByCategoryRowId: null,
answersByQuestionRowId: {
nodes: getAnswersPositionForQuestionRowId(2),
__typename: "AnswersConnection",
},
__typename: "Question",
},
{
id: "q3",
rowId: 3,
title: "Question 3?",
description: null,
categoryByCategoryRowId: null,
answersByQuestionRowId: {
nodes: getAnswersPositionForQuestionRowId(3),
__typename: "AnswersConnection",
},
__typename: "Question",
},
];
export const getAllQuestionAnswersMock: Array<
MockedResponse<GetAllQuestionAnswersResponse>
> = [
{
request: {
query: GET_ALL_QUESTION_ANSWERS,
variables: {
personRowId: 2,
} as GetAllQuestionAnswersVariables,
},
result: {
data: {
allQuestions: {
nodes: questionAnswersMock,
__typename: "QuestionsConnection",
},
},
},
},
];
export const getAnswerByQuestionAndPersonMock: Array<
MockedResponse<GetAnswerByQuestionAndPersonResponse>
> = [
...questionAnswersMock.map((q, index) => ({
request: {
query: GET_ANSWER_BY_QUESTION_AND_PERSON,
variables: {
personRowId: 2,
questionRowId: index + 1,
} as GetAnswerByQuestionAndPersonVariables,
},
result: {
data: {
answerByQuestionRowIdAndPersonRowId:
getAnswersForQuestionRowId(index + 1)[0] || null,
},
},
})),
];

View file

@ -0,0 +1,98 @@
import { gql } from "@apollo/client";
import { BasicQuestionFragment, BasicQuestionResponse } from "./question";
import { CandidatePosition } from "../../components/CandidatePositionLegend";
export const FullAnswerFragment = gql`
fragment FullAnswerFragment on Answer {
id
text
position
questionRowId
personRowId
}
`;
export interface FullAnswerResponse {
id: string;
text: string | null;
position: CandidatePosition;
questionRowId: number;
personRowId: number;
__typename: "Answer";
}
export const GET_ANSWER_BY_QUESTION_AND_PERSON = gql`
query GetAnswerByQuestionAndPerson($questionRowId: Int!, $personRowId: Int!) {
answerByQuestionRowIdAndPersonRowId(
personRowId: $personRowId
questionRowId: $questionRowId
) {
...FullAnswerFragment
}
}
${FullAnswerFragment}
`;
export interface GetAnswerByQuestionAndPersonResponse {
answerByQuestionRowIdAndPersonRowId: FullAnswerResponse | null;
}
export interface GetAnswerByQuestionAndPersonVariables {
personRowId: number;
questionRowId: number;
}
export const AnswerPositionFragment = gql`
fragment AnswerPositionFragment on Answer {
id
position
}
`;
export interface AnswerPositionResponse {
id: string;
position: CandidatePosition;
__typename: "Answer";
}
export const QuestionAnswerFragment = gql`
fragment QuestionAnswerFragment on Question {
...BasicQuestionFragment
answersByQuestionRowId(condition: { personRowId: $personRowId }) {
nodes {
...AnswerPositionFragment
}
}
}
${BasicQuestionFragment}
${AnswerPositionFragment}
`;
export interface QuestionAnswerResponse extends BasicQuestionResponse {
answersByQuestionRowId: {
nodes: Array<AnswerPositionResponse>;
__typename: "AnswersConnection";
};
}
export const GET_ALL_QUESTION_ANSWERS = gql`
query AllQuestionAnswers($personRowId: Int) {
allQuestions(orderBy: CATEGORY_ROW_ID_ASC) {
nodes {
...QuestionAnswerFragment
}
}
}
${QuestionAnswerFragment}
`;
export interface GetAllQuestionAnswersResponse {
allQuestions: {
nodes: Array<QuestionAnswerResponse>;
__typename: "QuestionsConnection";
};
}
export interface GetAllQuestionAnswersVariables {
personRowId?: number | null;
}

View file

@ -1,29 +1,32 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
BasicCategoryResponse, BasicCategoryResponse,
GET_ALL_CATEGORIES, GET_ALL_CATEGORIES,
GET_CATEGORY_BY_ID, GET_CATEGORY_BY_ID,
GetAllCategoriesResponse, GetAllCategoriesResponse,
GetCategoryByIdResponse GetCategoryByIdResponse,
} from "./category"; } from "./category";
export const categoryNodesMock: Array<BasicCategoryResponse> = [ export const categoryNodesMock: Array<BasicCategoryResponse> = [
{ {
id: "c1", id: "c1",
rowId: 1, rowId: 1,
title: "Category 1", title: "Category 1",
description: "Further information for C1", description: "Further information for C1",
__typename: "Category" __typename: "Category",
}, { },
{
id: "c2", id: "c2",
rowId: 2, rowId: 2,
title: "Category 2", title: "Category 2",
description: "Further information for C2", description: "Further information for C2",
__typename: "Category" __typename: "Category",
}]; },
];
export const getAllCategoriesMock: Array<MockedResponse<GetAllCategoriesResponse>> = [ export const getAllCategoriesMock: Array<
MockedResponse<GetAllCategoriesResponse>
> = [
{ {
request: { request: {
query: GET_ALL_CATEGORIES, query: GET_ALL_CATEGORIES,
@ -37,21 +40,24 @@ export const getAllCategoriesMock: Array<MockedResponse<GetAllCategoriesResponse
}, },
}, },
}, },
] ];
export const getCategoryByIdMock: Array<MockedResponse<GetCategoryByIdResponse>> = [...categoryNodesMock.map(c => ({ export const getCategoryByIdMock: Array<
request: { MockedResponse<GetCategoryByIdResponse>
query: GET_CATEGORY_BY_ID, > = [
variables: { ...categoryNodesMock.map((c) => ({
id: c.id, request: {
query: GET_CATEGORY_BY_ID,
variables: {
id: c.id,
},
}, },
}, result: {
result: { data: {
data: { category: c,
category: c, },
}, },
}, })),
})),
{ {
request: { request: {
query: GET_CATEGORY_BY_ID, query: GET_CATEGORY_BY_ID,
@ -64,5 +70,5 @@ export const getCategoryByIdMock: Array<MockedResponse<GetCategoryByIdResponse>>
category: null, category: null,
}, },
}, },
} },
] ];

View file

@ -1,4 +1,4 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
export const BasicCategoryFragment = gql` export const BasicCategoryFragment = gql`
fragment BasicCategoryFragment on Category { fragment BasicCategoryFragment on Category {
@ -7,14 +7,14 @@ export const BasicCategoryFragment = gql`
title title
description description
} }
` `;
export interface BasicCategoryResponse { export interface BasicCategoryResponse {
id: string, id: string;
rowId: number, rowId: number;
title: string, title: string;
description: string | null, description: string | null;
__typename: "Category", __typename: "Category";
} }
export const GET_ALL_CATEGORIES = gql` export const GET_ALL_CATEGORIES = gql`
@ -26,28 +26,28 @@ export const GET_ALL_CATEGORIES = gql`
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface GetAllCategoriesResponse { export interface GetAllCategoriesResponse {
allCategories: { allCategories: {
nodes: Array<BasicCategoryResponse>, nodes: Array<BasicCategoryResponse>;
__typename: "CategoriesConnection", __typename: "CategoriesConnection";
} };
} }
export const GET_CATEGORY_BY_ID = gql` export const GET_CATEGORY_BY_ID = gql`
query GetCategoryById($id:ID!) { query GetCategoryById($id: ID!) {
category(id: $id) { category(id: $id) {
...BasicCategoryFragment ...BasicCategoryFragment
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface GetCategoryByIdResponse { export interface GetCategoryByIdResponse {
category: BasicCategoryResponse | null, category: BasicCategoryResponse | null;
} }
export interface GetCategoryByIdVariables { export interface GetCategoryByIdVariables {
id: string, id: string;
} }

View file

@ -1,27 +1,29 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
BasicQuestionResponse, BasicQuestionResponse,
GET_ALL_QUESTIONS, GET_ALL_QUESTIONS,
GET_QUESTION_BY_ID, GET_QUESTION_BY_ID,
GetAllQuestionsResponse, GetAllQuestionsResponse,
GetQuestionByIdResponse GetQuestionByIdResponse,
} from "./question"; } from "./question";
export const questionNodesMock: Array<BasicQuestionResponse> = [
export const questionNodesMock: Array<BasicQuestionResponse> = [{ {
id: "q1", id: "q1",
title: "Question 1?",
description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
rowId: 1, rowId: 1,
title: "Category 1", title: "Question 1?",
__typename: "Category" description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
rowId: 1,
title: "Category 1",
__typename: "Category",
},
__typename: "Question",
}, },
__typename: "Question",
},
{ {
id: "q2", id: "q2",
rowId: 2,
title: "Question 2?", title: "Question 2?",
description: "Further information for Q2", description: "Further information for Q2",
categoryByCategoryRowId: null, categoryByCategoryRowId: null,
@ -29,14 +31,17 @@ export const questionNodesMock: Array<BasicQuestionResponse> = [{
}, },
{ {
id: "q3", id: "q3",
rowId: 3,
title: "Question 3?", title: "Question 3?",
description: null, description: null,
categoryByCategoryRowId: null, categoryByCategoryRowId: null,
__typename: "Question", __typename: "Question",
} },
]; ];
export const getAllQuestionsMock: Array<MockedResponse<GetAllQuestionsResponse>> = [ export const getAllQuestionsMock: Array<
MockedResponse<GetAllQuestionsResponse>
> = [
{ {
request: { request: {
query: GET_ALL_QUESTIONS, query: GET_ALL_QUESTIONS,
@ -46,14 +51,16 @@ export const getAllQuestionsMock: Array<MockedResponse<GetAllQuestionsResponse>>
allQuestions: { allQuestions: {
nodes: questionNodesMock, nodes: questionNodesMock,
__typename: "QuestionsConnection", __typename: "QuestionsConnection",
} },
} },
}, },
}, },
] ];
export const getQuestionByIdMock: Array<MockedResponse<GetQuestionByIdResponse>> = [ export const getQuestionByIdMock: Array<
...questionNodesMock.map(q => ({ MockedResponse<GetQuestionByIdResponse>
> = [
...questionNodesMock.map((q) => ({
request: { request: {
query: GET_QUESTION_BY_ID, query: GET_QUESTION_BY_ID,
variables: { variables: {
@ -78,6 +85,5 @@ export const getQuestionByIdMock: Array<MockedResponse<GetQuestionByIdResponse>>
question: null, question: null,
}, },
}, },
} },
] ];

View file

@ -1,4 +1,4 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
const QuestionCategoryFragment = gql` const QuestionCategoryFragment = gql`
fragment QuestionCategoryFragment on Category { fragment QuestionCategoryFragment on Category {
@ -6,18 +6,19 @@ const QuestionCategoryFragment = gql`
rowId rowId
title title
} }
` `;
interface GetQuestionsCategoryResponse { interface GetQuestionsCategoryResponse {
id: string, id: string;
rowId: number, rowId: number;
title: string, title: string;
__typename: "Category", __typename: "Category";
} }
export const BasicQuestionFragment = gql` export const BasicQuestionFragment = gql`
fragment BasicQuestionFragment on Question { fragment BasicQuestionFragment on Question {
id id
rowId
title title
description description
categoryByCategoryRowId { categoryByCategoryRowId {
@ -25,14 +26,15 @@ export const BasicQuestionFragment = gql`
} }
} }
${QuestionCategoryFragment} ${QuestionCategoryFragment}
` `;
export interface BasicQuestionResponse { export interface BasicQuestionResponse {
id: string, id: string;
title: string, rowId: number;
description: string | null, title: string;
categoryByCategoryRowId: GetQuestionsCategoryResponse | null, description: string | null;
__typename: "Question", categoryByCategoryRowId: GetQuestionsCategoryResponse | null;
__typename: "Question";
} }
export const GET_ALL_QUESTIONS = gql` export const GET_ALL_QUESTIONS = gql`
@ -44,28 +46,28 @@ export const GET_ALL_QUESTIONS = gql`
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface GetAllQuestionsResponse { export interface GetAllQuestionsResponse {
allQuestions: { allQuestions: {
nodes: Array<BasicQuestionResponse>, nodes: Array<BasicQuestionResponse>;
__typename: "QuestionsConnection", __typename: "QuestionsConnection";
} };
} }
export const GET_QUESTION_BY_ID = gql` export const GET_QUESTION_BY_ID = gql`
query GetQuestionById($id:ID!) { query GetQuestionById($id: ID!) {
question(id: $id) { question(id: $id) {
...BasicQuestionFragment ...BasicQuestionFragment
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface GetQuestionByIdResponse { export interface GetQuestionByIdResponse {
question: BasicQuestionResponse | null, question: BasicQuestionResponse | null;
} }
export interface GetQuestionByIdVariables { export interface GetQuestionByIdVariables {
id: string, id: string;
} }

View file

@ -0,0 +1,96 @@
import React from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Accordion from "@material-ui/core/Accordion";
import AccordionDetails from "@material-ui/core/AccordionDetails";
import AccordionSummary from "@material-ui/core/AccordionSummary";
import Typography from "@material-ui/core/Typography";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import Divider from "@material-ui/core/Divider";
import {
CandidatePosition,
getIconForPosition,
} from "./CandidatePositionLegend";
import { QuestionAnswerResponse } from "../backend/queries/answer";
import EditAnswerSection from "./EditAnswerSection";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
width: "100%",
marginBottom: theme.spacing(1),
},
heading: {
fontSize: theme.typography.pxToRem(15),
flexGrow: 1,
},
secondaryHeading: {
fontSize: theme.typography.pxToRem(15),
color: theme.palette.text.secondary,
},
details: {
flexDirection: "column",
},
questionDetails: {
marginBottom: theme.spacing(2),
},
positionIcon: {
marginLeft: theme.spacing(2),
},
})
);
interface AccordionQuestionAnswerProps {
personRowId: number;
question: QuestionAnswerResponse;
}
export default function AccordionQuestionAnswer(
props: AccordionQuestionAnswerProps
): React.ReactElement {
const {
rowId: questionRowId,
title: questionTitle,
description: questionDetails,
} = props.question;
const position = props.question.answersByQuestionRowId.nodes[0]?.position;
const questionCategory = props.question.categoryByCategoryRowId?.title;
const classes = useStyles();
const answerPosition =
position !== undefined ? position : CandidatePosition.skipped;
return (
<div className={classes.root} key={questionRowId}>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1c-content"
id="panel1c-header"
>
<div className={classes.heading}>
<Typography>{questionTitle}</Typography>
</div>
<div className={classes.secondaryHeading}>
<Typography>{questionCategory}</Typography>
</div>
<div className={classes.positionIcon}>
{getIconForPosition(answerPosition)}
</div>
</AccordionSummary>
<AccordionDetails className={classes.details}>
<Typography
className={classes.questionDetails}
color="textSecondary"
style={{ whiteSpace: "pre-line" }}
>
{questionDetails}
</Typography>
<Divider />
<EditAnswerSection
personRowId={props.personRowId}
question={props.question}
/>
</AccordionDetails>
</Accordion>
</div>
);
}

View file

@ -1,21 +1,21 @@
import React from 'react'; import React from "react";
import {createStyles, makeStyles, Theme} from '@material-ui/core/styles'; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Accordion from '@material-ui/core/Accordion'; import Accordion from "@material-ui/core/Accordion";
import AccordionDetails from '@material-ui/core/AccordionDetails'; import AccordionDetails from "@material-ui/core/AccordionDetails";
import AccordionSummary from '@material-ui/core/AccordionSummary'; import AccordionSummary from "@material-ui/core/AccordionSummary";
import AccordionActions from '@material-ui/core/AccordionActions'; import AccordionActions from "@material-ui/core/AccordionActions";
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from "@material-ui/icons/Delete";
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from "@material-ui/icons/Edit";
import Typography from '@material-ui/core/Typography'; import Typography from "@material-ui/core/Typography";
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import Divider from '@material-ui/core/Divider'; import Divider from "@material-ui/core/Divider";
import {IconButton} from '@material-ui/core'; import { IconButton } from "@material-ui/core";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
root: { root: {
width: '100%', width: "100%",
marginBottom: theme.spacing(1) marginBottom: theme.spacing(1),
}, },
heading: { heading: {
fontSize: theme.typography.pxToRem(15), fontSize: theme.typography.pxToRem(15),
@ -26,28 +26,30 @@ const useStyles = makeStyles((theme: Theme) =>
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}, },
details: { details: {
alignItems: 'center', alignItems: "center",
}, },
}), })
); );
interface AccordionWithEditProps { interface AccordionWithEditProps {
key: string, key: string;
title: string, title: string;
description: string | null, description: string | null;
subTitle?: string | null, subTitle?: string | null;
onEditButtonClick?(): void, onEditButtonClick?(): void;
onDeleteButtonClick?(): void, onDeleteButtonClick?(): void;
} }
export default function AccordionWithEdit(props: AccordionWithEditProps) { export default function AccordionWithEdit(
props: AccordionWithEditProps
): React.ReactElement {
const classes = useStyles(); const classes = useStyles();
return ( return (
<div className={classes.root}> <div className={classes.root}>
<Accordion> <Accordion>
<AccordionSummary <AccordionSummary
expandIcon={<ExpandMoreIcon/>} expandIcon={<ExpandMoreIcon />}
aria-controls="panel1c-content" aria-controls="panel1c-content"
id="panel1c-header" id="panel1c-header"
> >
@ -59,20 +61,30 @@ export default function AccordionWithEdit(props: AccordionWithEditProps) {
</div> </div>
</AccordionSummary> </AccordionSummary>
<AccordionDetails className={classes.details}> <AccordionDetails className={classes.details}>
<Typography color="textSecondary" style={{whiteSpace: "pre-line"}}> <Typography color="textSecondary" style={{ whiteSpace: "pre-line" }}>
{props.description} {props.description}
</Typography> </Typography>
</AccordionDetails> </AccordionDetails>
<Divider/> <Divider />
<AccordionActions> <AccordionActions>
<IconButton data-testid="edit-icon-button" size={"small"} aria-label="edit" onClick={props.onEditButtonClick}> <IconButton
<EditIcon titleAccess="Anpassen"/> data-testid="edit-icon-button"
size={"small"}
aria-label="edit"
onClick={props.onEditButtonClick}
>
<EditIcon titleAccess="Anpassen" />
</IconButton> </IconButton>
<IconButton data-testid="delete-icon-button" size={"small"} aria-label="delete" onClick={props.onDeleteButtonClick}> <IconButton
<DeleteIcon titleAccess="Löschen"/> data-testid="delete-icon-button"
size={"small"}
aria-label="delete"
onClick={props.onDeleteButtonClick}
>
<DeleteIcon titleAccess="Löschen" />
</IconButton> </IconButton>
</AccordionActions> </AccordionActions>
</Accordion> </Accordion>
</div> </div>
) );
} }

View file

@ -1,37 +1,37 @@
import {Card, CardActionArea, CardContent} from "@material-ui/core"; import { Card, CardActionArea, CardContent } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import AddIcon from '@material-ui/icons/Add'; import AddIcon from "@material-ui/icons/Add";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
width: '100%', width: "100%",
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
}, },
addCardContent: { addCardContent: {
padding: 0, padding: 0,
}, },
addCardIcon: { addCardIcon: {
display: 'block', display: "block",
margin: 'auto', margin: "auto",
padding: 12, padding: 12,
}, },
})); }));
interface AddCardProps { interface AddCardProps {
handleClick?(): void handleClick?(): void;
} }
export default function AddCard(props: AddCardProps) { export default function AddCard(props: AddCardProps): React.ReactElement {
const classes = useStyles(); const classes = useStyles();
return ( return (
<Card className={classes.root}> <Card className={classes.root}>
<CardActionArea onClick={props.handleClick}> <CardActionArea onClick={props.handleClick}>
<CardContent color={"textSecondary"} className={classes.addCardContent}> <CardContent color={"textSecondary"} className={classes.addCardContent}>
<AddIcon className={classes.addCardIcon}/> <AddIcon className={classes.addCardIcon} />
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
</Card> </Card>
) );
} }

View file

@ -1,57 +1,63 @@
import React from 'react'; import React from "react";
import {createStyles, makeStyles, Theme} from '@material-ui/core/styles'; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import CircularProgress from '@material-ui/core/CircularProgress'; import CircularProgress from "@material-ui/core/CircularProgress";
import {green} from '@material-ui/core/colors'; import { green } from "@material-ui/core/colors";
import Button from '@material-ui/core/Button'; import Button from "@material-ui/core/Button";
import { PropTypes } from "@material-ui/core";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
wrapper: { wrapper: {
margin: theme.spacing(1), margin: theme.spacing(1),
position: 'relative', position: "relative",
}, },
buttonProgress: { buttonProgress: {
color: green[500], color: green[500],
position: 'absolute', position: "absolute",
top: '50%', top: "50%",
left: '50%', left: "50%",
marginTop: -12, marginTop: -12,
marginLeft: -12, marginLeft: -12,
}, },
button: { button: {
margin: theme.spacing(3, 0, 2), margin: theme.spacing(3, 0, 2),
}, },
}), })
); );
interface ButtonWithSpinnerProps { interface ButtonWithSpinnerProps {
children: string, children: string;
onClick?: () => void, onClick?: () => void;
loading?: boolean loading?: boolean;
type?: "button" | "submit", type?: "button" | "submit";
fullWidth?: boolean, fullWidth?: boolean;
autoFocus?: boolean autoFocus?: boolean;
className?: string;
color?: PropTypes.Color;
} }
export default function ButtonWithSpinner(props: ButtonWithSpinnerProps) { export default function ButtonWithSpinner(
props: ButtonWithSpinnerProps
): React.ReactElement {
const classes = useStyles(); const classes = useStyles();
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<Button <Button
className={classes.button} className={`${classes.button} ${props.className}`}
variant="contained" variant="contained"
color="primary" color={props.color || "primary"}
fullWidth={!!props.fullWidth} fullWidth={!!props.fullWidth}
type={props.type} type={props.type}
disabled={props.loading} disabled={props.loading}
onClick={props.onClick} onClick={props.onClick}
autoFocus={props.autoFocus} autoFocus={props.autoFocus}
> >
{props.children} {props.children}
</Button> </Button>
{props.loading && <CircularProgress size={24} className={classes.buttonProgress} />} {props.loading && (
</div> <CircularProgress size={24} className={classes.buttonProgress} />
)}
</div>
); );
} }

View file

@ -0,0 +1,75 @@
import { Chip, SvgIconProps } from "@material-ui/core";
import ThumbUpIcon from "@material-ui/icons/ThumbUp";
import RadioButtonUncheckedIcon from "@material-ui/icons/RadioButtonUnchecked";
import { ThumbDown } from "@material-ui/icons";
import CloseIcon from "@material-ui/icons/Close";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
export enum CandidatePosition {
positive,
neutral,
negative,
skipped,
}
export const allPositions = [
CandidatePosition.positive,
CandidatePosition.neutral,
CandidatePosition.negative,
CandidatePosition.skipped,
];
const useStyles = makeStyles((theme) => ({
legend: {
position: "relative",
display: "flex",
flexDirection: "row",
justifyContent: "flex-end",
marginBottom: theme.spacing(2),
},
chip: {
marginLeft: theme.spacing(1),
},
}));
export function CandidatePositionLegend(): React.ReactElement {
const classes = useStyles();
const getChip = (position: CandidatePosition, legendText: string) => {
return (
<Chip
label={legendText}
color="primary"
icon={getIconForPosition(position, { fontSize: "inherit" })}
variant="outlined"
className={classes.chip}
/>
);
};
return (
<div className={classes.legend}>
{getChip(CandidatePosition.positive, "Ich bin dafür")}
{getChip(CandidatePosition.neutral, "Neutral")}
{getChip(CandidatePosition.negative, "Ich bin dagegen")}
{getChip(CandidatePosition.skipped, "Frage überspringen")}
</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} />;
}
};

View file

@ -1,60 +1,71 @@
import {Paper, Typography} from "@material-ui/core"; import { Paper, Typography } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {useQuery} from "@apollo/client"; import { useQuery } from "@apollo/client";
import AddCard from "./AddCard"; import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit"; import AccordionWithEdit from "./AccordionWithEdit";
import {BasicCategoryResponse, GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category"; import {
import DialogChangeCategory, {dialogChangeCategoryId, dialogChangeCategoryOpen} from "./DialogChangeCategory"; BasicCategoryResponse,
GET_ALL_CATEGORIES,
GetAllCategoriesResponse,
} from "../backend/queries/category";
import DialogChangeCategory, {
dialogChangeCategoryId,
dialogChangeCategoryOpen,
} from "./DialogChangeCategory";
import DialogDeleteCategory, { import DialogDeleteCategory, {
dialogDeleteCategoryId, dialogDeleteCategoryId,
dialogDeleteCategoryOpen, dialogDeleteCategoryOpen,
dialogDeleteCategoryTitle dialogDeleteCategoryTitle,
} from "./DialogDeleteCategory"; } from "./DialogDeleteCategory";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
width: '100%', width: "100%",
padding: theme.spacing(1), padding: theme.spacing(1),
marginBottom: theme.spacing(3), marginBottom: theme.spacing(3),
}, },
})); }));
export default function CategoryList() { 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 classes = useStyles();
const handleAddClick = () => { const handleAddClick = () => {
dialogChangeCategoryId("") dialogChangeCategoryId("");
dialogChangeCategoryOpen(true) dialogChangeCategoryOpen(true);
} };
const handleEditButtonClick = (category: BasicCategoryResponse) => { const handleEditButtonClick = (category: BasicCategoryResponse) => {
dialogChangeCategoryId(category.id); dialogChangeCategoryId(category.id);
dialogChangeCategoryOpen(true) dialogChangeCategoryOpen(true);
}; };
const handleDeleteButtonClick = (category: BasicCategoryResponse) => { const handleDeleteButtonClick = (category: BasicCategoryResponse) => {
dialogDeleteCategoryTitle(category.title); dialogDeleteCategoryTitle(category.title);
dialogDeleteCategoryId(category.id); dialogDeleteCategoryId(category.id);
dialogDeleteCategoryOpen(true); dialogDeleteCategoryOpen(true);
} };
return ( return (
<Paper className={classes.root}> <Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Kategorien</Typography> <Typography component={"h2"} variant="h6" color="primary" gutterBottom>
{categories?.map(category => <AccordionWithEdit Kategorien
</Typography>
{categories?.map((category) => (
<AccordionWithEdit
key={category.id} key={category.id}
title={category.title} title={category.title}
description={category.description} description={category.description}
onEditButtonClick={() => handleEditButtonClick(category)} onEditButtonClick={() => handleEditButtonClick(category)}
onDeleteButtonClick={() => handleDeleteButtonClick(category)} onDeleteButtonClick={() => handleDeleteButtonClick(category)}
/> />
)} ))}
<AddCard handleClick={handleAddClick}/> <AddCard handleClick={handleAddClick} />
<DialogChangeCategory/> <DialogChangeCategory />
<DialogDeleteCategory/> <DialogDeleteCategory />
</Paper> </Paper>
) );
} }

View file

@ -1,20 +1,23 @@
import React, {ChangeEvent} from 'react'; import React, { ChangeEvent } from "react";
import {FormControl, InputLabel, MenuItem, Select} from "@material-ui/core"; import { FormControl, InputLabel, MenuItem, Select } from "@material-ui/core";
import {BasicCategoryResponse} from "../backend/queries/category"; import { BasicCategoryResponse } from "../backend/queries/category";
interface CategorySelectionMenuProps { interface CategorySelectionMenuProps {
selectedCategoryId: number | null selectedCategoryId: number | null;
categories?: Array<BasicCategoryResponse>, categories?: Array<BasicCategoryResponse>;
handleCategoryChange(categoryId: number | null): void handleCategoryChange(categoryId: number | null): void;
} }
export default function CategorySelectionMenu(props: CategorySelectionMenuProps) { export default function CategorySelectionMenu(
const onCategoryIdChange = (e: ChangeEvent<{ name?: string, value: unknown }>) => { props: CategorySelectionMenuProps
const newValue = e.target.value === -1 ? null : e.target.value as number; ): React.ReactElement {
const onCategoryIdChange = (
e: ChangeEvent<{ name?: string; value: unknown }>
) => {
const newValue = e.target.value === -1 ? null : (e.target.value as number);
props.handleCategoryChange(newValue); props.handleCategoryChange(newValue);
} };
return ( return (
<FormControl fullWidth variant="outlined"> <FormControl fullWidth variant="outlined">
@ -27,9 +30,11 @@ export default function CategorySelectionMenu(props: CategorySelectionMenuProps)
<MenuItem value={-1}> <MenuItem value={-1}>
<em>None</em> <em>None</em>
</MenuItem> </MenuItem>
{props.categories?.map(category => <MenuItem key={category.id} value={category.rowId}> {props.categories?.map((category) => (
{category.title} <MenuItem key={category.id} value={category.rowId}>
</MenuItem>)} {category.title}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
); );

View file

@ -2,15 +2,15 @@ import Typography from "@material-ui/core/Typography";
import Link from "@material-ui/core/Link"; import Link from "@material-ui/core/Link";
import React from "react"; import React from "react";
export function Copyright() { export function Copyright(): React.ReactElement {
return ( return (
<Typography variant="body2" color="textSecondary" align="center"> <Typography variant="body2" color="textSecondary" align="center">
{'Copyright © '} {"Copyright © "}
<Link color="inherit" href="https://blog.netzbegruenung.de/"> <Link color="inherit" href="https://blog.netzbegruenung.de/">
Netzbegruenung e.V. Netzbegruenung e.V.
</Link>{' '} </Link>{" "}
{new Date().getFullYear()} {new Date().getFullYear()}
{'.'} {"."}
</Typography> </Typography>
); );
} }

View file

@ -1,12 +1,11 @@
import React from 'react'; import React from "react";
import AppBar from '@material-ui/core/AppBar'; import AppBar from "@material-ui/core/AppBar";
import {IconButton, MenuItem, Toolbar, Typography} from '@material-ui/core'; import { IconButton, MenuItem, Toolbar, Typography } from "@material-ui/core";
import MenuIcon from '@material-ui/icons/Menu'; import MenuIcon from "@material-ui/icons/Menu";
import Menu from '@material-ui/core/Menu'; import Menu from "@material-ui/core/Menu";
import AccountCircle from '@material-ui/icons/AccountCircle'; import AccountCircle from "@material-ui/icons/AccountCircle";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {useHistory} from 'react-router-dom'; import { useHistory } from "react-router-dom";
const useStyles = makeStyles({ const useStyles = makeStyles({
menuButton: { menuButton: {
@ -15,10 +14,10 @@ const useStyles = makeStyles({
title: { title: {
flexGrow: 1, flexGrow: 1,
}, },
}) });
function CustomAppBar() { function CustomAppBar(): React.ReactElement {
const classes = useStyles() const classes = useStyles();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const history = useHistory(); const history = useHistory();
@ -28,52 +27,57 @@ function CustomAppBar() {
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('token') localStorage.removeItem("token");
history.push("/login") history.push("/login");
} };
const handleClose = () => { const handleClose = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
return ( return (
<AppBar> <AppBar>
<Toolbar> <Toolbar>
<IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="menu"> <IconButton
<MenuIcon /> edge="start"
</IconButton> className={classes.menuButton}
<Typography variant="h6" className={classes.title}> color="inherit"
Candymat aria-label="menu"
</Typography> >
<IconButton <MenuIcon />
aria-label="account of current user" </IconButton>
aria-controls="menu-appbar" <Typography variant="h6" className={classes.title}>
aria-haspopup="true" Candymat
onClick={handleMenu} </Typography>
color="inherit" <IconButton
> aria-label="account of current user"
<AccountCircle /> aria-controls="menu-appbar"
</IconButton> aria-haspopup="true"
<Menu onClick={handleMenu}
id="menu-appbar" color="inherit"
anchorEl={anchorEl} >
anchorOrigin={{ <AccountCircle />
vertical: 'top', </IconButton>
horizontal: 'right', <Menu
}} id="menu-appbar"
keepMounted anchorEl={anchorEl}
transformOrigin={{ anchorOrigin={{
vertical: 'top', vertical: "top",
horizontal: 'right', horizontal: "right",
}} }}
open={open} keepMounted
onClose={handleClose} transformOrigin={{
> vertical: "top",
<MenuItem onClick={handleClose}>Profil</MenuItem> horizontal: "right",
<MenuItem onClick={handleLogout}>Logout</MenuItem> }}
</Menu> open={open}
</Toolbar> onClose={handleClose}
</AppBar> >
); <MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</Toolbar>
</AppBar>
);
} }
export default CustomAppBar export default CustomAppBar;

View file

@ -1,23 +1,31 @@
import {Button, DialogActions} from "@material-ui/core"; import { Button, DialogActions } from "@material-ui/core";
import ButtonWithSpinner from "./ButtonWithSpinner"; import ButtonWithSpinner from "./ButtonWithSpinner";
import React from "react"; import React from "react";
interface DialogSimpleActionProps { interface DialogSimpleActionProps {
confirmButtonText?: string, confirmButtonText?: string;
loading?: boolean, loading?: boolean;
onClose(): void, onClose(): void;
onConfirmButtonClick(): void, onConfirmButtonClick(): void;
} }
export function DialogActionBar(props: DialogSimpleActionProps) { export function DialogActionBar(
return <DialogActions> props: DialogSimpleActionProps
<Button onClick={props.onClose} color="primary"> ): React.ReactElement {
Abbrechen return (
</Button> <DialogActions>
<ButtonWithSpinner onClick={props.onConfirmButtonClick} autoFocus loading={props.loading}> <Button onClick={props.onClose} color="primary">
{props.confirmButtonText || "Ok"} Abbrechen
</ButtonWithSpinner> </Button>
</DialogActions>; <ButtonWithSpinner
onClick={props.onConfirmButtonClick}
autoFocus
loading={props.loading}
>
{props.confirmButtonText || "Ok"}
</ButtonWithSpinner>
</DialogActions>
);
} }

View file

@ -1,17 +1,17 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import Dialog from '@material-ui/core/Dialog'; import Dialog from "@material-ui/core/Dialog";
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from "@material-ui/core/DialogTitle";
import {DialogActionBar} from "./DialogActionBar"; import { DialogActionBar } from "./DialogActionBar";
import {DialogTitleAndDetails} from "./DialogTitleAndDetails"; import { DialogTitleAndDetails } from "./DialogTitleAndDetails";
import {makeVar, useMutation, useQuery, useReactiveVar} from "@apollo/client"; import { makeVar, useMutation, useQuery, useReactiveVar } from "@apollo/client";
import {useSnackbar} from "notistack"; import { useSnackbar } from "notistack";
import { import {
BasicCategoryFragment, BasicCategoryFragment,
BasicCategoryResponse, BasicCategoryResponse,
GET_CATEGORY_BY_ID, GET_CATEGORY_BY_ID,
GetCategoryByIdResponse, GetCategoryByIdResponse,
GetCategoryByIdVariables GetCategoryByIdVariables,
} from "../backend/queries/category"; } from "../backend/queries/category";
import { import {
ADD_CATEGORY, ADD_CATEGORY,
@ -19,65 +19,89 @@ import {
AddCategoryVariables, AddCategoryVariables,
EDIT_CATEGORY, EDIT_CATEGORY,
EditCategoryResponse, EditCategoryResponse,
EditCategoryVariables EditCategoryVariables,
} from "../backend/mutations/category"; } from "../backend/mutations/category";
export const dialogChangeCategoryId = makeVar<string>(""); export const dialogChangeCategoryId = makeVar<string>("");
export const dialogChangeCategoryOpen = makeVar<boolean>(false); export const dialogChangeCategoryOpen = makeVar<boolean>(false);
export default function DialogChangeCategory() { export default function DialogChangeCategory(): React.ReactElement {
const [addMode, setAddMode] = useState(true); const [addMode, setAddMode] = useState(true);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [details, setDetails] = useState(""); const [details, setDetails] = useState("");
const categoryId = useReactiveVar(dialogChangeCategoryId); const categoryId = useReactiveVar(dialogChangeCategoryId);
const open = useReactiveVar(dialogChangeCategoryOpen); const open = useReactiveVar(dialogChangeCategoryOpen);
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
useQuery<GetCategoryByIdResponse, GetCategoryByIdVariables>(GET_CATEGORY_BY_ID, { useQuery<GetCategoryByIdResponse, GetCategoryByIdVariables>(
variables: { GET_CATEGORY_BY_ID,
id: categoryId, {
}, variables: {
onCompleted: (data => { id: categoryId,
setAddMode(!data.category && !categoryId) },
setTitle(data.category?.title || ""); onCompleted: (data) => {
setDetails(data.category?.description || "") setAddMode(!data.category && !categoryId);
}) setTitle(data.category?.title || "");
}); setDetails(data.category?.description || "");
const [editCategory, {loading: editLoading}] = useMutation<EditCategoryResponse, EditCategoryVariables>(EDIT_CATEGORY, { },
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), }
);
const [editCategory, { loading: editLoading }] = useMutation<
EditCategoryResponse,
EditCategoryVariables
>(EDIT_CATEGORY, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.updateCategory) { if (response.updateCategory) {
enqueueSnackbar("Kategorie erfolgreich geändert.", {variant: "success"}) enqueueSnackbar("Kategorie erfolgreich geändert.", {
variant: "success",
});
dialogChangeCategoryOpen(false); dialogChangeCategoryOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
} variant: "error",
} });
});
const [addCategory, {loading: addLoading}] = useMutation<AddCategoryResponse, AddCategoryVariables>(ADD_CATEGORY, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}),
onCompleted: (response) => {
if (response.createCategory) {
enqueueSnackbar("Kategorie erfolgreich hinzugefügt.", {variant: "success"})
dialogChangeCategoryOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"})
} }
}, },
update: (cache, {data}) => { });
const [addCategory, { loading: addLoading }] = useMutation<
AddCategoryResponse,
AddCategoryVariables
>(ADD_CATEGORY, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => {
if (response.createCategory) {
enqueueSnackbar("Kategorie erfolgreich hinzugefügt.", {
variant: "success",
});
dialogChangeCategoryOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
}
},
update: (cache, { data }) => {
cache.modify({ cache.modify({
fields: { fields: {
allCategories(existingCategories = {nodes: []}) { allCategories(existingCategories = { nodes: [] }) {
const newCategoryRef = cache.writeFragment<BasicCategoryResponse | undefined>({ const newCategoryRef = cache.writeFragment<
BasicCategoryResponse | undefined
>({
data: data?.createCategory?.category, data: data?.createCategory?.category,
fragment: BasicCategoryFragment, fragment: BasicCategoryFragment,
fragmentName: "BasicCategoryFragment", fragmentName: "BasicCategoryFragment",
}); });
return {nodes: [...existingCategories.nodes, newCategoryRef]}; return { nodes: [...existingCategories.nodes, newCategoryRef] };
} },
} },
}); });
} },
}); });
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
@ -86,21 +110,25 @@ export default function DialogChangeCategory() {
variables: { variables: {
title, title,
description: details, description: details,
} },
}) });
} else { } else {
editCategory({ editCategory({
variables: { variables: {
id: categoryId, id: categoryId,
title: title, title: title,
description: details, description: details,
} },
}) });
} }
} };
return ( return (
<Dialog open={open} onClose={() => dialogChangeCategoryOpen(false)} aria-labelledby="form-dialog-title"> <Dialog
open={open}
onClose={() => dialogChangeCategoryOpen(false)}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title"> <DialogTitle id="form-dialog-title">
{addMode ? "Neue Kategorie erstellen" : "Kategorie bearbeiten"} {addMode ? "Neue Kategorie erstellen" : "Kategorie bearbeiten"}
</DialogTitle> </DialogTitle>
@ -108,8 +136,8 @@ export default function DialogChangeCategory() {
<DialogTitleAndDetails <DialogTitleAndDetails
title={title} title={title}
details={details} details={details}
onTitleChange={newTitle => setTitle(newTitle)} onTitleChange={(newTitle) => setTitle(newTitle)}
onDetailsChange={newDetails => setDetails(newDetails)} onDetailsChange={(newDetails) => setDetails(newDetails)}
/> />
</DialogContent> </DialogContent>
<DialogActionBar <DialogActionBar

View file

@ -1,88 +1,116 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import Dialog from '@material-ui/core/Dialog'; import Dialog from "@material-ui/core/Dialog";
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from "@material-ui/core/DialogTitle";
import {DialogActionBar} from "./DialogActionBar"; import { DialogActionBar } from "./DialogActionBar";
import {DialogTitleAndDetails} from "./DialogTitleAndDetails"; import { DialogTitleAndDetails } from "./DialogTitleAndDetails";
import {makeVar, useMutation, useQuery, useReactiveVar} from "@apollo/client"; import { makeVar, useMutation, useQuery, useReactiveVar } from "@apollo/client";
import {useSnackbar} from "notistack"; import { useSnackbar } from "notistack";
import { import {
ADD_QUESTION, ADD_QUESTION,
AddQuestionResponse, AddQuestionResponse,
AddQuestionVariables, AddQuestionVariables,
EDIT_QUESTION, EDIT_QUESTION,
EditQuestionResponse, EditQuestionResponse,
EditQuestionVariables EditQuestionVariables,
} from "../backend/mutations/question"; } from "../backend/mutations/question";
import { import {
BasicQuestionFragment, BasicQuestionFragment,
BasicQuestionResponse, BasicQuestionResponse,
GET_QUESTION_BY_ID, GET_QUESTION_BY_ID,
GetQuestionByIdResponse, GetQuestionByIdResponse,
GetQuestionByIdVariables GetQuestionByIdVariables,
} from "../backend/queries/question"; } from "../backend/queries/question";
import CategorySelectionMenu from "./CategorySelectionMenu"; import CategorySelectionMenu from "./CategorySelectionMenu";
import {GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category"; import {
GET_ALL_CATEGORIES,
GetAllCategoriesResponse,
} from "../backend/queries/category";
export const dialogChangeQuestionId = makeVar<string>(""); export const dialogChangeQuestionId = makeVar<string>("");
export const dialogChangeQuestionOpen = makeVar<boolean>(false); export const dialogChangeQuestionOpen = makeVar<boolean>(false);
export default function DialogChangeQuestion() { export default function DialogChangeQuestion(): React.ReactElement {
const [addMode, setAddMode] = useState(true); const [addMode, setAddMode] = useState(true);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [details, setDetails] = useState(""); const [details, setDetails] = useState("");
const [categoryRowId, setCategoryRowId] = useState<number | null>(null); const [categoryRowId, setCategoryRowId] = useState<number | null>(null);
const questionId = useReactiveVar(dialogChangeQuestionId); const questionId = useReactiveVar(dialogChangeQuestionId);
const open = useReactiveVar(dialogChangeQuestionOpen); const open = useReactiveVar(dialogChangeQuestionOpen);
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
useQuery<GetQuestionByIdResponse, GetQuestionByIdVariables>(GET_QUESTION_BY_ID, { useQuery<GetQuestionByIdResponse, GetQuestionByIdVariables>(
variables: { GET_QUESTION_BY_ID,
id: questionId, {
}, variables: {
onCompleted: (data => { id: questionId,
setAddMode(!data.question && !questionId) },
setTitle(data.question?.title || ""); onCompleted: (data) => {
setDetails(data.question?.description || ""); setAddMode(!data.question && !questionId);
setCategoryRowId(data.question?.categoryByCategoryRowId?.rowId || null) setTitle(data.question?.title || "");
}) setDetails(data.question?.description || "");
}) setCategoryRowId(data.question?.categoryByCategoryRowId?.rowId || null);
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, EditQuestionVariables>(EDIT_QUESTION, { const [editQuestion, { loading: editLoading }] = useMutation<
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), EditQuestionResponse,
EditQuestionVariables
>(EDIT_QUESTION, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.updateQuestion) { if (response.updateQuestion) {
enqueueSnackbar("Frage erfolgreich geändert.", {variant: "success"}) enqueueSnackbar("Frage erfolgreich geändert.", { variant: "success" });
dialogChangeQuestionOpen(false); dialogChangeQuestionOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
} variant: "error",
} });
});
const [addQuestion, {loading: addLoading}] = useMutation<AddQuestionResponse, AddQuestionVariables>(ADD_QUESTION, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}),
onCompleted: (response) => {
if (response.createQuestion) {
enqueueSnackbar("Frage erfolgreich hinzugefügt.", {variant: "success"})
dialogChangeQuestionOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"})
} }
}, },
update: (cache, {data}) => { });
const [addQuestion, { loading: addLoading }] = useMutation<
AddQuestionResponse,
AddQuestionVariables
>(ADD_QUESTION, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => {
if (response.createQuestion) {
enqueueSnackbar("Frage erfolgreich hinzugefügt.", {
variant: "success",
});
dialogChangeQuestionOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
}
},
update: (cache, { data }) => {
cache.modify({ cache.modify({
fields: { fields: {
allQuestions(existingQuestions = {nodes: []}) { allQuestions(existingQuestions = { nodes: [] }) {
const newQuestionRef = cache.writeFragment<BasicQuestionResponse | undefined>({ const newQuestionRef = cache.writeFragment<
BasicQuestionResponse | undefined
>({
data: data?.createQuestion?.question, data: data?.createQuestion?.question,
fragment: BasicQuestionFragment, fragment: BasicQuestionFragment,
fragmentName: "BasicQuestionFragment", fragmentName: "BasicQuestionFragment",
}); });
return {nodes: [...existingQuestions.nodes, newQuestionRef]}; return { nodes: [...existingQuestions.nodes, newQuestionRef] };
} },
} },
}); });
} },
}); });
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
@ -92,8 +120,8 @@ export default function DialogChangeQuestion() {
title, title,
description: details, description: details,
categoryRowId: categoryRowId, categoryRowId: categoryRowId,
} },
}) });
} else { } else {
editQuestion({ editQuestion({
variables: { variables: {
@ -101,13 +129,17 @@ export default function DialogChangeQuestion() {
title: title, title: title,
description: details, description: details,
categoryRowId: categoryRowId, categoryRowId: categoryRowId,
} },
}) });
} }
} };
return ( return (
<Dialog open={open} onClose={() => dialogChangeQuestionOpen(false)} aria-labelledby="form-dialog-title"> <Dialog
open={open}
onClose={() => dialogChangeQuestionOpen(false)}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title"> <DialogTitle id="form-dialog-title">
{addMode ? "Neue Frage erstellen" : "Frage bearbeiten"} {addMode ? "Neue Frage erstellen" : "Frage bearbeiten"}
</DialogTitle> </DialogTitle>
@ -115,8 +147,8 @@ export default function DialogChangeQuestion() {
<DialogTitleAndDetails <DialogTitleAndDetails
title={title} title={title}
details={details} details={details}
onTitleChange={newTitle => setTitle(newTitle)} onTitleChange={(newTitle) => setTitle(newTitle)}
onDetailsChange={newDetails => setDetails(newDetails)} onDetailsChange={(newDetails) => setDetails(newDetails)}
/> />
<CategorySelectionMenu <CategorySelectionMenu
selectedCategoryId={categoryRowId} selectedCategoryId={categoryRowId}

View file

@ -1,37 +1,61 @@
import React from 'react'; import React from "react";
import {makeVar, Reference, useMutation, useReactiveVar} from "@apollo/client"; import {
makeVar,
Reference,
useMutation,
useReactiveVar,
} from "@apollo/client";
import DialogSimple from "./DialogSimple"; import DialogSimple from "./DialogSimple";
import {useSnackbar} from "notistack"; import { useSnackbar } from "notistack";
import {DELETE_CATEGORY, DeleteCategoryResponse, DeleteCategoryVariables} from "../backend/mutations/category"; import {
DELETE_CATEGORY,
DeleteCategoryResponse,
DeleteCategoryVariables,
} from "../backend/mutations/category";
export const dialogDeleteCategoryId = makeVar<string>(""); export const dialogDeleteCategoryId = makeVar<string>("");
export const dialogDeleteCategoryTitle = makeVar<string>(""); export const dialogDeleteCategoryTitle = makeVar<string>("");
export const dialogDeleteCategoryOpen = makeVar<boolean>(false); export const dialogDeleteCategoryOpen = makeVar<boolean>(false);
export default function DialogDeleteCategory() { export default function DialogDeleteCategory(): React.ReactElement {
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [deleteCategory, {loading}] = useMutation<DeleteCategoryResponse, DeleteCategoryVariables>(DELETE_CATEGORY, { const [deleteCategory, { loading }] = useMutation<
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), DeleteCategoryResponse,
DeleteCategoryVariables
>(DELETE_CATEGORY, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.deleteCategory) { if (response.deleteCategory) {
enqueueSnackbar("Kategorie erfolgreich gelöscht.", {variant: "success"}) enqueueSnackbar("Kategorie erfolgreich gelöscht.", {
variant: "success",
});
dialogDeleteCategoryOpen(false); dialogDeleteCategoryOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
} }
}, },
update: (cache, {data}) => { update: (cache, { data }) => {
const idToRemove = data?.deleteCategory?.category.id; const idToRemove = data?.deleteCategory?.category.id;
cache.modify({ cache.modify({
fields: { fields: {
allCategories(existingCategoriesRef: { nodes: Array<Reference> } = {nodes: []}, {readField}) { allCategories(
console.log("existingCategory: ", existingCategoriesRef) existingCategoriesRef: { nodes: Array<Reference> } = { nodes: [] },
return {nodes: existingCategoriesRef.nodes.filter(categoryRef => readField('id', categoryRef) !== idToRemove)}; { readField }
} ) {
} return {
nodes: existingCategoriesRef.nodes.filter(
(categoryRef) => readField("id", categoryRef) !== idToRemove
),
};
},
},
}); });
} },
}); });
const open = useReactiveVar(dialogDeleteCategoryOpen); const open = useReactiveVar(dialogDeleteCategoryOpen);
@ -41,10 +65,10 @@ export default function DialogDeleteCategory() {
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
deleteCategory({ deleteCategory({
variables: { variables: {
id id,
} },
}) });
} };
return ( return (
<DialogSimple <DialogSimple
@ -58,4 +82,3 @@ export default function DialogDeleteCategory() {
/> />
); );
} }

View file

@ -1,36 +1,59 @@
import React from 'react'; import React from "react";
import {makeVar, Reference, useMutation, useReactiveVar} from "@apollo/client"; import {
makeVar,
Reference,
useMutation,
useReactiveVar,
} from "@apollo/client";
import DialogSimple from "./DialogSimple"; import DialogSimple from "./DialogSimple";
import {DELETE_QUESTION, DeleteQuestionResponse, DeleteQuestionVariables} from "../backend/mutations/question"; import {
import {useSnackbar} from "notistack"; DELETE_QUESTION,
DeleteQuestionResponse,
DeleteQuestionVariables,
} from "../backend/mutations/question";
import { useSnackbar } from "notistack";
export const dialogDeleteQuestionId = makeVar<string>(""); export const dialogDeleteQuestionId = makeVar<string>("");
export const dialogDeleteQuestionTitle = makeVar<string>(""); export const dialogDeleteQuestionTitle = makeVar<string>("");
export const dialogDeleteQuestionOpen = makeVar<boolean>(false); export const dialogDeleteQuestionOpen = makeVar<boolean>(false);
export default function DialogDeleteQuestion() { export default function DialogDeleteQuestion(): React.ReactElement {
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [deleteQuestion, {loading}] = useMutation<DeleteQuestionResponse, DeleteQuestionVariables>(DELETE_QUESTION, { const [deleteQuestion, { loading }] = useMutation<
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), DeleteQuestionResponse,
DeleteQuestionVariables
>(DELETE_QUESTION, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.deleteQuestion) { if (response.deleteQuestion) {
enqueueSnackbar("Frage erfolgreich gelöscht.", {variant: "success"}) enqueueSnackbar("Frage erfolgreich gelöscht.", { variant: "success" });
dialogDeleteQuestionOpen(false); dialogDeleteQuestionOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
} }
}, },
update: (cache, {data}) => { update: (cache, { data }) => {
const idToRemove = data?.deleteQuestion?.question.id; const idToRemove = data?.deleteQuestion?.question.id;
cache.modify({ cache.modify({
fields: { fields: {
allQuestions(existingQuestionsRef: { nodes: Array<Reference> } = {nodes: []}, {readField}) { allQuestions(
return {nodes: existingQuestionsRef.nodes.filter(questionRef => readField('id', questionRef) !== idToRemove)}; existingQuestionsRef: { nodes: Array<Reference> } = { nodes: [] },
} { readField }
} ) {
return {
nodes: existingQuestionsRef.nodes.filter(
(questionRef) => readField("id", questionRef) !== idToRemove
),
};
},
},
}); });
} },
}); });
const open = useReactiveVar(dialogDeleteQuestionOpen); const open = useReactiveVar(dialogDeleteQuestionOpen);
@ -40,10 +63,10 @@ export default function DialogDeleteQuestion() {
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
deleteQuestion({ deleteQuestion({
variables: { variables: {
id id,
} },
}) });
} };
return ( return (
<DialogSimple <DialogSimple
@ -57,4 +80,3 @@ export default function DialogDeleteQuestion() {
/> />
); );
} }

View file

@ -1,24 +1,25 @@
import React from 'react'; import React from "react";
import Dialog from '@material-ui/core/Dialog'; import Dialog from "@material-ui/core/Dialog";
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from "@material-ui/core/DialogTitle";
import {DialogContentText} from "@material-ui/core"; import { DialogContentText } from "@material-ui/core";
import {DialogActionBar} from "./DialogActionBar"; import { DialogActionBar } from "./DialogActionBar";
interface DialogSimpleProps { interface DialogSimpleProps {
open: boolean, open: boolean;
title: string, title: string;
confirmButtonText: string, confirmButtonText: string;
description: string, description: string;
loading?: boolean, loading?: boolean;
onConfirmButtonClick(): void, onConfirmButtonClick(): void;
onClose(): void, onClose(): void;
} }
export default function DialogSimple(props: DialogSimpleProps) { export default function DialogSimple(
props: DialogSimpleProps
): React.ReactElement {
return ( return (
<Dialog <Dialog
open={props.open} open={props.open}
@ -40,4 +41,3 @@ export default function DialogSimple(props: DialogSimpleProps) {
</Dialog> </Dialog>
); );
} }

View file

@ -1,23 +1,25 @@
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import React from "react"; import React from "react";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
textField: { textField: {
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
} },
})); }));
interface DialogTitleAndDetailsProps { interface DialogTitleAndDetailsProps {
title: string, title: string;
details?: string | null, details?: string | null;
onTitleChange(newTitle: string): void, onTitleChange(newTitle: string): void;
onDetailsChange(newDetails: string): void, onDetailsChange(newDetails: string): void;
} }
export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) { export function DialogTitleAndDetails(
props: DialogTitleAndDetailsProps
): React.ReactElement {
const classes = useStyles(); const classes = useStyles();
return ( return (
@ -29,7 +31,7 @@ export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) {
fullWidth fullWidth
variant="outlined" variant="outlined"
value={props.title} value={props.title}
onChange={e => props.onTitleChange(e.target.value)} onChange={(e) => props.onTitleChange(e.target.value)}
/> />
<TextField <TextField
className={classes.textField} className={classes.textField}
@ -40,8 +42,8 @@ export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) {
fullWidth fullWidth
variant="outlined" variant="outlined"
value={props.details} value={props.details}
onChange={e => props.onDetailsChange(e.target.value)} onChange={(e) => props.onDetailsChange(e.target.value)}
/> />
</React.Fragment> </React.Fragment>
) );
} }

View file

@ -0,0 +1,159 @@
import React from "react";
import { CandidatePosition } from "./CandidatePositionLegend";
import { useMutation, useQuery } from "@apollo/client";
import {
FullAnswerResponse,
GET_ANSWER_BY_QUESTION_AND_PERSON,
GetAnswerByQuestionAndPersonResponse,
GetAnswerByQuestionAndPersonVariables,
QuestionAnswerResponse,
} from "../backend/queries/answer";
import { useSnackbar } from "notistack";
import {
ADD_ANSWER,
AddAnswerResponse,
AddAnswerVariables,
EDIT_ANSWER,
EditAnswerResponse,
EditAnswerVariables,
updateCacheAfterAddingAnswer,
} from "../backend/mutations/answer";
import ToggleButtonGroupAnswerPosition from "./ToggleButtonGroupAnswerPosition";
import EditAnswerText from "./EditAnswerText";
interface EditAnswerSectionProps {
personRowId: number;
question: QuestionAnswerResponse;
}
export default function EditAnswerSection(
props: EditAnswerSectionProps
): React.ReactElement {
const { enqueueSnackbar } = useSnackbar();
const { data } = useQuery<
GetAnswerByQuestionAndPersonResponse,
GetAnswerByQuestionAndPersonVariables
>(GET_ANSWER_BY_QUESTION_AND_PERSON, {
variables: {
personRowId: props.personRowId,
questionRowId: props.question.rowId,
},
});
const remoteAnswer = data?.answerByQuestionRowIdAndPersonRowId;
const [editAnswer, { loading: editAnswerLoading }] = useMutation<
EditAnswerResponse,
EditAnswerVariables
>(EDIT_ANSWER, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
});
const [addAnswer, { loading: addAnswerLoading }] = useMutation<
AddAnswerResponse,
AddAnswerVariables
>(ADD_ANSWER, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
update: (cache, fetchResult) =>
updateCacheAfterAddingAnswer(cache, fetchResult, props.question),
});
const parsePosition = (position?: CandidatePosition): CandidatePosition => {
return position !== undefined ? position : CandidatePosition.skipped;
};
const changeAnswer = async (
position?: CandidatePosition,
text?: string
): Promise<FullAnswerResponse | undefined> => {
if (remoteAnswer) {
const optimisticResponseAnswer = {
...remoteAnswer,
...(position !== undefined && { position }),
...(text !== undefined && { text }),
};
const response = await editAnswer({
variables: {
id: remoteAnswer.id,
position,
text,
},
optimisticResponse: {
updateAnswer: {
__typename: "UpdateAnswerPayload",
answer: optimisticResponseAnswer,
},
},
});
return response.data?.updateAnswer?.answer;
} else {
const savePosition = parsePosition(position);
const response = await addAnswer({
variables: {
personRowId: props.personRowId,
questionRowId: props.question.rowId,
position: savePosition,
text: text,
},
optimisticResponse: {
createAnswer: {
__typename: "CreateAnswerPayload",
answer: {
id: "somethingIntermediate",
position: savePosition,
text: text || null,
personRowId: props.personRowId,
questionRowId: props.question.rowId,
__typename: "Answer",
},
},
},
});
return response.data?.createAnswer?.answer;
}
};
const handleSaveText = async (text: string) => {
const newAnswer = await changeAnswer(undefined, text);
if (newAnswer) {
enqueueSnackbar("Antwort erfolgreich gespeichert.", {
variant: "success",
});
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
}
};
const handlePositionChange = async (
e: React.MouseEvent<HTMLElement>,
newPosition: CandidatePosition
) => {
const newAnswer = await changeAnswer(newPosition);
if (!newAnswer) {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
}
};
const loading = editAnswerLoading || addAnswerLoading;
const position = parsePosition(remoteAnswer?.position);
return remoteAnswer === undefined ? (
<div>Antwort laden...</div>
) : (
<React.Fragment>
<ToggleButtonGroupAnswerPosition
position={position}
onPositionChange={handlePositionChange}
loading={loading}
/>
<EditAnswerText
remoteText={remoteAnswer?.text}
onSaveClick={handleSaveText}
loading={loading}
/>
</React.Fragment>
);
}

View file

@ -0,0 +1,75 @@
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import React, { useState } from "react";
import { FormLabel } from "@material-ui/core";
import TextField from "@material-ui/core/TextField";
import ButtonWithSpinner from "./ButtonWithSpinner";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
button: {
marginLeft: "auto",
marginRight: 0,
marginTop: 0,
marginBottom: 0,
},
root: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
detailedAnswerActions: {
display: "flex",
flexDirection: "row",
justifyContent: "flex-end",
},
})
);
interface EditAnswerTextSectionProps {
remoteText: string | null | undefined;
loading?: boolean;
onSaveClick(text: string): void;
}
export default function EditAnswerText(
props: EditAnswerTextSectionProps
): React.ReactElement {
const classes = useStyles();
const initialAnswer = props.remoteText || "Antwort hinzufügen...";
const [answerText, setAnswerText] = useState<string>(initialAnswer);
return (
<div className={classes.root}>
<FormLabel>Detaillierte Antwort</FormLabel>
<TextField
multiline
rows={4}
id="description"
fullWidth
variant="outlined"
value={answerText}
onChange={(e) => {
e.preventDefault();
setAnswerText(e.target.value);
}}
/>
<div className={classes.detailedAnswerActions}>
<ButtonWithSpinner
color="default"
className={classes.button}
onClick={() => setAnswerText(initialAnswer)}
loading={props.loading}
>
Zurücksetzen
</ButtonWithSpinner>
<ButtonWithSpinner
loading={props.loading}
className={classes.button}
onClick={() => props.onSaveClick(answerText)}
>
Speichern
</ButtonWithSpinner>
</div>
</div>
);
}

View file

@ -1,19 +1,27 @@
import React from 'react'; import React from "react";
import {render, screen} from '@testing-library/react' import { render, screen } from "@testing-library/react";
import {MockedProvider} from '@apollo/client/testing'; import { MockedProvider } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import Main from "./Main"; import Main from "./Main";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
function renderMainPage() { function renderMainPage() {
render(<MockedProvider><MemoryRouter><SnackbarProvider><Main/></SnackbarProvider></MemoryRouter></MockedProvider>); render(
<MockedProvider>
<MemoryRouter>
<SnackbarProvider>
<Main />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
} }
describe('The main page', () => { describe("The main page", () => {
test('displays the editors page if an editor is logged in', () => { test("displays the editors page if an editor is logged in", () => {
const editorToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c"; const editorToken =
localStorage.setItem("token", editorToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c";
localStorage.setItem("token", editorToken);
renderMainPage(); renderMainPage();
// it renders question and category lists // it renders question and category lists
@ -23,30 +31,35 @@ describe('The main page', () => {
expect(categoryListHeadline).not.toBeNull(); expect(categoryListHeadline).not.toBeNull();
}); });
test('displays the candidates page if a candidate is logged in', () => { test("displays the candidates page if a candidate is logged in", () => {
const candidateToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg"; const candidateToken =
localStorage.setItem("token", candidateToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg";
localStorage.setItem("token", candidateToken);
renderMainPage(); renderMainPage();
const placeholder = screen.queryByText(/Under construction/); const questionListHeadline = screen.queryByText(/Fragen/);
expect(placeholder).not.toBeNull(); const categoryListHeadline = screen.queryByText(/Kategorien/);
expect(questionListHeadline).not.toBeNull();
expect(categoryListHeadline).toBeNull();
}); });
test('displays the user page if an normal user is logged in', () => { test("displays the user page if an normal user is logged in", () => {
const userToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o"; const userToken =
localStorage.setItem("token", userToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", userToken);
renderMainPage(); renderMainPage();
const placeholder = screen.queryByText(/nichts zu sehen/); const placeholder = screen.queryByText(/nichts zu sehen/);
expect(placeholder).not.toBeNull(); expect(placeholder).not.toBeNull();
}); });
test('displays a link to the loggin page if something is wrong with the token', () => { test("displays a link to the loggin page if something is wrong with the token", () => {
const invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o"; const invalidToken =
localStorage.setItem("token", invalidToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", invalidToken);
renderMainPage(); renderMainPage();
const placeholder = screen.queryByRole("link", {name: /Login Seite/}); const placeholder = screen.queryByRole("link", { name: /Login Seite/ });
expect(placeholder).not.toBeNull(); expect(placeholder).not.toBeNull();
}); });
}); });

View file

@ -1,53 +1,60 @@
import CustomAppBar from "./CustomAppBar"; import CustomAppBar from "./CustomAppBar";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {MainPageEditor} from "./MainPageEditor"; import { MainPageEditor } from "./MainPageEditor";
import {getJsonWebToken} from "../jwt/jwt"; import { getJsonWebToken } from "../jwt/jwt";
import {MainPageCandidate} from "./MainPageCandidate"; import { MainPageCandidate } from "./MainPageCandidate";
import {MainPageUser} from "./MainPageUser"; import { MainPageUser } from "./MainPageUser";
import {Link} from "react-router-dom"; import { Link } from "react-router-dom";
import {Container} from "@material-ui/core"; import { Container } from "@material-ui/core";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
appBarSpacer: theme.mixins.toolbar, appBarSpacer: theme.mixins.toolbar,
content: { content: {
flexGrow: 1, flexGrow: 1,
height: '100vh', height: "100vh",
overflow: 'auto', overflow: "auto",
}, },
invalidTokenContainer: { invalidTokenContainer: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
} },
})); }));
function Main() { function Main(): React.ReactElement {
const classes = useStyles(); const classes = useStyles();
const getMainPage = () => { const getMainPage = () => {
switch (getJsonWebToken()?.role) { const jwt = getJsonWebToken();
case "candymat_editor": if (jwt) {
return <MainPageEditor/>; switch (jwt.role) {
case "candymat_candidate": case "candymat_editor":
return <MainPageCandidate/>; return <MainPageEditor />;
case "candymat_person": case "candymat_candidate":
return <MainPageUser/>; return <MainPageCandidate personRowId={jwt.person_row_id} />;
default: case "candymat_person":
localStorage.removeItem('token'); return <MainPageUser />;
return <Container className={classes.invalidTokenContainer}> }
Du bist nicht eingelogged oder dein Token ist ungültig. Logge dich erneut ein.<br/> } 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> Zur <Link to={"/login"}>Login Seite</Link>
</Container> </Container>
);
} }
} };
return ( return (
<div> <div>
<CustomAppBar/> <CustomAppBar />
<main className={classes.content}> <main className={classes.content}>
<div className={classes.appBarSpacer}/> <div className={classes.appBarSpacer} />
{getMainPage()} {getMainPage()}
</main> </main>
</div> </div>
) );
} }
export default Main; export default Main;

View file

@ -1,21 +1,27 @@
import {Container} from "@material-ui/core"; import { Container } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import QuestionAnswersList from "./QuestionAnswerList";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
container: { container: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
flexDirection: 'column', flexDirection: "column",
}, },
})); }));
export function MainPageCandidate() { interface MainPageCandidateProps {
const classes = useStyles(); personRowId: number;
}
export function MainPageCandidate(
props: MainPageCandidateProps
): React.ReactElement {
const classes = useStyles();
return ( return (
<Container maxWidth="lg" className={classes.container}> <Container maxWidth="lg" className={classes.container}>
Under construction <QuestionAnswersList personRowId={props.personRowId} />
</Container> </Container>
); );
} }

View file

@ -1,26 +1,26 @@
import {Container} from "@material-ui/core"; import { Container } from "@material-ui/core";
import QuestionList from "./QuestionList"; import QuestionList from "./QuestionList";
import CategoryList from "./CategoryList"; import CategoryList from "./CategoryList";
import {Copyright} from "./Copyright"; import { Copyright } from "./Copyright";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
container: { container: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
flexDirection: 'column', flexDirection: "column",
}, },
})); }));
export function MainPageEditor() { export function MainPageEditor(): React.ReactElement {
const classes = useStyles(); const classes = useStyles();
return ( return (
<Container maxWidth="lg" className={classes.container}> <Container maxWidth="lg" className={classes.container}>
<QuestionList/> <QuestionList />
<CategoryList/> <CategoryList />
<Copyright/> <Copyright />
</Container> </Container>
); );
} }

View file

@ -1,16 +1,16 @@
import {Container} from "@material-ui/core"; import { Container } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
container: { container: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
flexDirection: 'column', flexDirection: "column",
}, },
})); }));
export function MainPageUser() { export function MainPageUser(): React.ReactElement {
const classes = useStyles(); const classes = useStyles();
return ( return (

View file

@ -0,0 +1,54 @@
import React from "react";
import { useQuery } from "@apollo/client";
import {
GET_ALL_QUESTION_ANSWERS,
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables,
} from "../backend/queries/answer";
import { Paper, Typography } from "@material-ui/core";
import { CandidatePositionLegend } from "./CandidatePositionLegend";
import AccordionQuestionAnswer from "./AccordionQuestionAnswer";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles((theme) => ({
root: {
width: "100%",
padding: theme.spacing(1),
marginBottom: theme.spacing(3),
backgroundColor: theme.palette.background.default,
},
}));
interface QuestionAnswerListProps {
personRowId: number;
}
export default function QuestionAnswersList(
props: QuestionAnswerListProps
): React.ReactElement {
const classes = useStyles();
const questionAnswers = useQuery<
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables
>(GET_ALL_QUESTION_ANSWERS, {
variables: {
personRowId: props.personRowId,
},
}).data?.allQuestions.nodes;
return (
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
Fragen
</Typography>
<CandidatePositionLegend />
{questionAnswers?.map((question) => (
<AccordionQuestionAnswer
key={question.rowId}
personRowId={props.personRowId}
question={question}
/>
))}
</Paper>
);
}

View file

@ -1,49 +1,60 @@
import {Paper, Typography} from "@material-ui/core"; import { Paper, Typography } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {useQuery} from "@apollo/client"; import { useQuery } from "@apollo/client";
import AddCard from "./AddCard"; import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit"; import AccordionWithEdit from "./AccordionWithEdit";
import {BasicQuestionResponse, GET_ALL_QUESTIONS, GetAllQuestionsResponse} from "../backend/queries/question"; import {
import DialogChangeQuestion, {dialogChangeQuestionId, dialogChangeQuestionOpen} from "./DialogChangeQuestion"; BasicQuestionResponse,
GET_ALL_QUESTIONS,
GetAllQuestionsResponse,
} from "../backend/queries/question";
import DialogChangeQuestion, {
dialogChangeQuestionId,
dialogChangeQuestionOpen,
} from "./DialogChangeQuestion";
import DialogDeleteQuestion, { import DialogDeleteQuestion, {
dialogDeleteQuestionId, dialogDeleteQuestionId,
dialogDeleteQuestionOpen, dialogDeleteQuestionOpen,
dialogDeleteQuestionTitle dialogDeleteQuestionTitle,
} from "./DialogDeleteQuestion"; } from "./DialogDeleteQuestion";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
width: '100%', width: "100%",
padding: theme.spacing(1), padding: theme.spacing(1),
marginBottom: theme.spacing(3), marginBottom: theme.spacing(3),
}, },
})); }));
export default function QuestionList() { 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 classes = useStyles();
const handleAddButtonClick = () => { const handleAddButtonClick = () => {
dialogChangeQuestionId("") dialogChangeQuestionId("");
dialogChangeQuestionOpen(true) dialogChangeQuestionOpen(true);
} };
const handleEditButtonClick = (question: BasicQuestionResponse) => { const handleEditButtonClick = (question: BasicQuestionResponse) => {
dialogChangeQuestionId(question.id) dialogChangeQuestionId(question.id);
dialogChangeQuestionOpen(true) dialogChangeQuestionOpen(true);
}; };
const handleDeleteButtonClick = (question: BasicQuestionResponse) => { const handleDeleteButtonClick = (question: BasicQuestionResponse) => {
dialogDeleteQuestionTitle(question.title); dialogDeleteQuestionTitle(question.title);
dialogDeleteQuestionId(question.id); dialogDeleteQuestionId(question.id);
dialogDeleteQuestionOpen(true); dialogDeleteQuestionOpen(true);
} };
return ( return (
<Paper className={classes.root}> <Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Fragen</Typography> <Typography component={"h2"} variant="h6" color="primary" gutterBottom>
{questions?.map(question => <AccordionWithEdit Fragen
</Typography>
{questions?.map((question) => (
<AccordionWithEdit
key={question.id} key={question.id}
title={question.title} title={question.title}
subTitle={question.categoryByCategoryRowId?.title} subTitle={question.categoryByCategoryRowId?.title}
@ -51,11 +62,10 @@ export default function QuestionList() {
onEditButtonClick={() => handleEditButtonClick(question)} onEditButtonClick={() => handleEditButtonClick(question)}
onDeleteButtonClick={() => handleDeleteButtonClick(question)} onDeleteButtonClick={() => handleDeleteButtonClick(question)}
/> />
)} ))}
<AddCard handleClick={handleAddButtonClick}/> <AddCard handleClick={handleAddButtonClick} />
<DialogChangeQuestion/> <DialogChangeQuestion />
<DialogDeleteQuestion/> <DialogDeleteQuestion />
</Paper> </Paper>
) );
} }

View file

@ -1,76 +1,79 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import Avatar from '@material-ui/core/Avatar'; import Avatar from "@material-ui/core/Avatar";
import CssBaseline from '@material-ui/core/CssBaseline'; import CssBaseline from "@material-ui/core/CssBaseline";
import TextField from '@material-ui/core/TextField'; import TextField from "@material-ui/core/TextField";
import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormControlLabel from "@material-ui/core/FormControlLabel";
import Checkbox from '@material-ui/core/Checkbox'; import Checkbox from "@material-ui/core/Checkbox";
import Grid from '@material-ui/core/Grid'; import Grid from "@material-ui/core/Grid";
import Box from '@material-ui/core/Box'; import Box from "@material-ui/core/Box";
import {Alert} from '@material-ui/lab'; import { Alert } from "@material-ui/lab";
import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
import {Link, useHistory, useLocation} from 'react-router-dom'; import { Link, useHistory, useLocation } from "react-router-dom";
import Typography from '@material-ui/core/Typography'; import Typography from "@material-ui/core/Typography";
import {makeStyles} from '@material-ui/core/styles'; import { makeStyles } from "@material-ui/core/styles";
import Container from '@material-ui/core/Container'; import Container from "@material-ui/core/Container";
import {useMutation} from "@apollo/client"; import { useMutation } from "@apollo/client";
import ButtonWithSpinner from "./ButtonWithSpinner"; import ButtonWithSpinner from "./ButtonWithSpinner";
import {Copyright} from "./Copyright"; import { Copyright } from "./Copyright";
import {LOGIN, LoginResponse, LoginVariables} from "../backend/mutations/login"; import {
LOGIN,
LoginResponse,
LoginVariables,
} from "../backend/mutations/login";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
paper: { paper: {
marginTop: theme.spacing(8), marginTop: theme.spacing(8),
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}, },
avatar: { avatar: {
margin: theme.spacing(1), margin: theme.spacing(1),
backgroundColor: theme.palette.secondary.main, backgroundColor: theme.palette.secondary.main,
}, },
form: { form: {
width: '100%', // Fix IE 11 issue. width: "100%", // Fix IE 11 issue.
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
}, },
submit: { submit: {
margin: theme.spacing(3, 0, 2), margin: theme.spacing(3, 0, 2),
}, },
alert: { alert: {
margin: theme.spacing(1) margin: theme.spacing(1),
} },
})); }));
export default function SignIn(): React.ReactElement {
export default function SignIn() {
const history = useHistory(); const history = useHistory();
const queryParams = new URLSearchParams(useLocation().search); const queryParams = new URLSearchParams(useLocation().search);
const classes = useStyles(); const classes = useStyles();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [login, {loading}] = useMutation<LoginResponse, LoginVariables>( const [login, { loading }] = useMutation<LoginResponse, LoginVariables>(
LOGIN, LOGIN,
{ {
onCompleted(data) { onCompleted(data) {
if (data.authenticate.jwtToken) { if (data.authenticate.jwtToken) {
localStorage.setItem("token", data.authenticate.jwtToken) localStorage.setItem("token", data.authenticate.jwtToken);
history.replace("/") history.replace("/");
} else { } else {
setError("Wrong username or password.") setError("Wrong username or password.");
} }
}, },
onError(e) { onError(e) {
setError(`Error while trying to log in: ${e.message}`) setError(`Error while trying to log in: ${e.message}`);
} },
} }
); );
return ( return (
<Container component="main" maxWidth="xs"> <Container component="main" maxWidth="xs">
<CssBaseline/> <CssBaseline />
<div className={classes.paper}> <div className={classes.paper}>
<Avatar className={classes.avatar}> <Avatar className={classes.avatar}>
<LockOutlinedIcon/> <LockOutlinedIcon />
</Avatar> </Avatar>
<Typography component="h1" variant="h5"> <Typography component="h1" variant="h5">
Sign in Sign in
@ -78,9 +81,12 @@ export default function SignIn() {
<form <form
className={classes.form} className={classes.form}
noValidate noValidate
onSubmit={event => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
login({variables: {email: email, password: password}}).catch(error => console.log(error)) // fixme: logging?????
login({
variables: { email: email, password: password },
}).catch((error) => console.log(error));
}} }}
> >
<TextField <TextField
@ -117,14 +123,12 @@ export default function SignIn() {
/> />
<FormControlLabel <FormControlLabel
disabled={true} disabled={true}
control={<Checkbox value="remember" color="primary"/>} control={<Checkbox value="remember" color="primary" />}
label="Remember me" label="Remember me"
/> />
<ButtonWithSpinner <ButtonWithSpinner loading={loading} type="submit" fullWidth>
loading={loading} Sign In
type="submit" </ButtonWithSpinner>
fullWidth
>Sign In</ButtonWithSpinner>
<Grid container> <Grid container>
<Grid item xs> <Grid item xs>
{/* todo: see issue #17*/} {/* todo: see issue #17*/}
@ -133,27 +137,32 @@ export default function SignIn() {
{/*</Link>*/} {/*</Link>*/}
</Grid> </Grid>
<Grid item> <Grid item>
<Link to="/signup"> <Link to="/signup">{"Don't have an account? Sign Up"}</Link>
{"Don't have an account? Sign Up"}
</Link>
</Grid> </Grid>
</Grid> </Grid>
{queryParams.get("recent-sign-up-success") {queryParams.get("recent-sign-up-success") ? (
? <Alert className={classes.alert} severity="success" onClose={() => history.push("/login")}> <Alert
className={classes.alert}
severity="success"
onClose={() => history.push("/login")}
>
Sign-Up was successful. Log in to continue Sign-Up was successful. Log in to continue
</Alert> </Alert>
: null} ) : null}
{error ? <Alert className={classes.alert} severity="error" onClose={() => setError("")}>{error}</Alert> : null} {error ? (
<Alert
className={classes.alert}
severity="error"
onClose={() => setError("")}
>
{error}
</Alert>
) : null}
</form> </form>
</div> </div>
<Box mt={8}> <Box mt={8}>
<Copyright/> <Copyright />
</Box> </Box>
</Container> </Container>
); );
} }

View file

@ -1,110 +1,120 @@
import React, {ChangeEvent, useState} from 'react'; import React, { ChangeEvent, useState } from "react";
import Avatar from '@material-ui/core/Avatar'; import Avatar from "@material-ui/core/Avatar";
import CssBaseline from '@material-ui/core/CssBaseline'; import CssBaseline from "@material-ui/core/CssBaseline";
import TextField from '@material-ui/core/TextField'; import TextField from "@material-ui/core/TextField";
import {Link, useHistory} from 'react-router-dom'; import { Link, useHistory } from "react-router-dom";
import Grid from '@material-ui/core/Grid'; import Grid from "@material-ui/core/Grid";
import Box from '@material-ui/core/Box'; import Box from "@material-ui/core/Box";
import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
import Typography from '@material-ui/core/Typography'; import Typography from "@material-ui/core/Typography";
import {makeStyles} from '@material-ui/core/styles'; import { makeStyles } from "@material-ui/core/styles";
import Container from '@material-ui/core/Container'; import Container from "@material-ui/core/Container";
import {Copyright} from "./Copyright"; import { Copyright } from "./Copyright";
import {useMutation} from "@apollo/client"; import { useMutation } from "@apollo/client";
import ButtonWithSpinner from "./ButtonWithSpinner"; import ButtonWithSpinner from "./ButtonWithSpinner";
import {errorHandler, SignUpError} from "./SignUpErrorHandler"; import { errorHandler, SignUpError } from "./SignUpErrorHandler";
import {Alert} from "@material-ui/lab"; import { Alert } from "@material-ui/lab";
import {SIGN_UP, SignUpResponse, SignUpVariables} from "../backend/mutations/signUp"; import {
SIGN_UP,
SignUpResponse,
SignUpVariables,
} from "../backend/mutations/signUp";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
paper: { paper: {
marginTop: theme.spacing(8), marginTop: theme.spacing(8),
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}, },
avatar: { avatar: {
margin: theme.spacing(1), margin: theme.spacing(1),
backgroundColor: theme.palette.secondary.main, backgroundColor: theme.palette.secondary.main,
}, },
form: { form: {
width: '100%', // Fix IE 11 issue. width: "100%", // Fix IE 11 issue.
marginTop: theme.spacing(3), marginTop: theme.spacing(3),
}, },
submit: { submit: {
margin: theme.spacing(3, 0, 2), margin: theme.spacing(3, 0, 2),
}, },
error: { error: {
color: 'red', color: "red",
}, },
alert: { alert: {
margin: theme.spacing(1) margin: theme.spacing(1),
} },
})); }));
export default function SignUp() { export default function SignUp(): React.ReactElement {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
const [error, setError] = useState<SignUpError | undefined>(undefined) const [error, setError] = useState<SignUpError | undefined>(undefined);
const history = useHistory(); const history = useHistory();
const [createAccount, {loading}] = useMutation<SignUpResponse, SignUpVariables>( const [createAccount, { loading }] = useMutation<
SIGN_UP, SignUpResponse,
{ SignUpVariables
onCompleted() { >(SIGN_UP, {
history.push("/login?recent-sign-up-success=true") onCompleted() {
}, history.push("/login?recent-sign-up-success=true");
onError(e) { },
console.error(e); onError(e) {
setPassword(""); console.error(e);
setError(errorHandler(e)) setPassword("");
} setError(errorHandler(e));
} },
); });
const classes = useStyles(); const classes = useStyles();
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => { const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
createAccount({variables: {firstName, lastName, email, password}}); createAccount({ variables: { firstName, lastName, email, password } });
} };
const onFirstNameChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onFirstNameChange = (
setFirstName(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setFirstName(e.target.value);
if (error?.firstNameInvalid) { if (error?.firstNameInvalid) {
setError(undefined) setError(undefined);
} }
} };
const onLastNameChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onLastNameChange = (
setLastName(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setLastName(e.target.value);
if (error?.lastNameInvalid) { if (error?.lastNameInvalid) {
setError(undefined) setError(undefined);
} }
} };
const onEmailChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onEmailChange = (
setEmail(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setEmail(e.target.value);
if (error?.emailInvalid) { if (error?.emailInvalid) {
setError(undefined) setError(undefined);
} }
} };
const onPasswordChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onPasswordChange = (
setPassword(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setPassword(e.target.value);
if (error?.passwordInvalid) { if (error?.passwordInvalid) {
setError(undefined) setError(undefined);
} }
} };
return ( return (
<Container component="main" maxWidth="xs"> <Container component="main" maxWidth="xs">
<CssBaseline/> <CssBaseline />
<div className={classes.paper}> <div className={classes.paper}>
<Avatar className={classes.avatar}> <Avatar className={classes.avatar}>
<LockOutlinedIcon/> <LockOutlinedIcon />
</Avatar> </Avatar>
<Typography component="h1" variant="h5"> <Typography component="h1" variant="h5">
Sign up Sign up
@ -170,33 +180,28 @@ export default function SignUp() {
/> />
</Grid> </Grid>
</Grid> </Grid>
<ButtonWithSpinner <ButtonWithSpinner loading={loading} type="submit" fullWidth>
loading={loading}
type="submit"
fullWidth
>
Sign Up Sign Up
</ButtonWithSpinner> </ButtonWithSpinner>
<Grid container justify="flex-end"> <Grid container justify="flex-end">
<Grid item> <Grid item>
<Link to="/login"> <Link to="/login">Already have an account? Sign in</Link>
Already have an account? Sign in
</Link>
</Grid> </Grid>
</Grid> </Grid>
{ {error ? (
error <Alert
? <Alert className={classes.alert} severity="error" onClose={() => setError(undefined)}> className={classes.alert}
{error.message} severity="error"
</Alert> onClose={() => setError(undefined)}
: null >
} {error.message}
</Alert>
) : null}
</form> </form>
</div> </div>
<Box mt={5}> <Box mt={5}>
<Copyright/> <Copyright />
</Box> </Box>
</Container> </Container>
); );
} }

View file

@ -1,62 +1,68 @@
import {ApolloError} from "@apollo/client"; import { ApolloError } from "@apollo/client";
export interface SignUpError { export interface SignUpError {
message: string, message: string;
emailInvalid: boolean, emailInvalid: boolean;
firstNameInvalid: boolean, firstNameInvalid: boolean;
lastNameInvalid: boolean, lastNameInvalid: boolean;
passwordInvalid: boolean passwordInvalid: boolean;
} }
const parseErrorMessage = (error: ApolloError): string => { const parseErrorMessage = (error: ApolloError): string => {
let result = "Sign-up failed because of the following reason(s): "; let result = "Sign-up failed because of the following reason(s): ";
if (isEmailAlreadyUsed(error)) { if (isEmailAlreadyUsed(error)) {
result += "The E-Mail is already in use. " result += "The E-Mail is already in use. ";
} }
if (isFirstNameInvalid(error)) { if (isFirstNameInvalid(error)) {
result += "The provided 'First Name' is invalid. " result += "The provided 'First Name' is invalid. ";
} }
if (isLastNameInvalid(error)) { if (isLastNameInvalid(error)) {
result += "The provided 'Last Name' is invalid. " result += "The provided 'Last Name' is invalid. ";
} }
if (isPasswordInvalid(error)) { if (isPasswordInvalid(error)) {
result += "The provided password is invalid. " result += "The provided password is invalid. ";
} }
return result return result;
} };
const isEmailAlreadyUsed = (error: ApolloError): boolean => { const isEmailAlreadyUsed = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("unique-constraint") && errorMessage.includes("email"); return (
} errorMessage.includes("unique-constraint") && errorMessage.includes("email")
);
};
const isFirstNameInvalid = (error: ApolloError): boolean => { const isFirstNameInvalid = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("invalid") && errorMessage.includes("first name"); return (
} errorMessage.includes("invalid") && errorMessage.includes("first name")
);
};
const isLastNameInvalid = (error: ApolloError): boolean => { const isLastNameInvalid = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("invalid") && errorMessage.includes("last name"); return errorMessage.includes("invalid") && errorMessage.includes("last name");
} };
const isPasswordInvalid = (error: ApolloError): boolean => { const isPasswordInvalid = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("invalid") && errorMessage.includes("password"); return errorMessage.includes("invalid") && errorMessage.includes("password");
} };
export const errorHandler = (error: undefined | ApolloError): undefined | SignUpError => {
return error ? {
message: parseErrorMessage(error),
emailInvalid: isEmailAlreadyUsed(error),
firstNameInvalid: isFirstNameInvalid(error),
lastNameInvalid: isLastNameInvalid(error),
passwordInvalid: isPasswordInvalid(error)
} : undefined
}
export const errorHandler = (
error: undefined | ApolloError
): undefined | SignUpError => {
return error
? {
message: parseErrorMessage(error),
emailInvalid: isEmailAlreadyUsed(error),
firstNameInvalid: isFirstNameInvalid(error),
lastNameInvalid: isLastNameInvalid(error),
passwordInvalid: isPasswordInvalid(error),
}
: undefined;
};

View file

@ -0,0 +1,59 @@
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import React from "react";
import { FormLabel } from "@material-ui/core";
import { ToggleButton, ToggleButtonGroup } from "@material-ui/lab";
import {
allPositions,
CandidatePosition,
getIconForPosition,
} from "./CandidatePositionLegend";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
})
);
interface ToggleButtonGroupAnswerPositionProps {
position: CandidatePosition;
loading?: boolean;
onPositionChange(
e: React.MouseEvent<HTMLElement>,
newPosition: CandidatePosition
): void;
}
export default function ToggleButtonGroupAnswerPosition(
props: ToggleButtonGroupAnswerPositionProps
): React.ReactElement {
const classes = useStyles();
return (
<div className={classes.root}>
<FormLabel component="legend">Deine Position</FormLabel>
<ToggleButtonGroup
value={props.position}
exclusive
onChange={props.onPositionChange}
>
{allPositions.map((position) => (
<ToggleButton
disabled={props.loading}
key={position}
value={position}
aria-label={position.toString()}
>
{getIconForPosition(position, {
color: "primary",
fontSize: "small",
})}
</ToggleButton>
))}
</ToggleButtonGroup>
</div>
);
}

View file

@ -1,13 +1,13 @@
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace; monospace;
} }

View file

@ -1,22 +1,22 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import './index.css'; import "./index.css";
import App from './App'; import App from "./App";
import * as serviceWorker from './serviceWorker'; import * as serviceWorker from "./serviceWorker";
import {ApolloProvider} from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
import {client} from "./backend/helper"; import { client } from "./backend/helper";
import {BrowserRouter as Router} from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
ReactDOM.render( ReactDOM.render(
<ApolloProvider client={client}> <ApolloProvider client={client}>
<Router> <Router>
<SnackbarProvider maxSnack={3}> <SnackbarProvider maxSnack={3}>
<App/> <App />
</SnackbarProvider> </SnackbarProvider>
</Router> </Router>
</ApolloProvider>, </ApolloProvider>,
document.getElementById('root') document.getElementById("root")
); );
// If you want your app to work offline and load faster, you can change // If you want your app to work offline and load faster, you can change

View file

@ -0,0 +1,252 @@
import React from "react";
import {
findByRole,
findByText,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import { SnackbarProvider } from "notistack";
import {
getAllQuestionAnswersMock,
getAnswerByQuestionAndPersonMock,
questionAnswersMock,
} from "../backend/queries/answer.mock";
import {
addAnswerMock,
editAnswerMock,
} from "../backend/mutations/answer.mock";
import {
getNegativePositionPath,
getNeutralPositionPath,
getPositivePositionPath,
getSkippedPositionPath,
queryAllIconButtons,
} from "./test-helper";
import QuestionAnswersList from "../components/QuestionAnswerList";
describe("The AnswerList", () => {
test("displays the existing answers, but not the details of it", async () => {
renderQuestionAnswerList();
const questionAnswerCards = await waitForQuestionsToRender();
questionAnswerCards.forEach((card) => {
expect(card.innerHTML).toMatch(/Question [1-3]/);
});
const saveButtons = screen.queryAllByRole("button", { name: /speichern/i });
expect(saveButtons).toHaveLength(0);
expect(questionAnswerCards[0].innerHTML).toContain(
getNeutralPositionPath()
);
expect(questionAnswerCards[1].innerHTML).toContain(
getPositivePositionPath()
);
expect(questionAnswerCards[2].innerHTML).toContain(
getSkippedPositionPath()
);
});
test("enables resetting an answer to the last saved state", async () => {
renderQuestionAnswerList(editAnswerMock);
const questionAnswerCards = await waitForQuestionsToRender();
let answerSection = await expandAccordionAndGetAnswerSection(
questionAnswerCards[1]
);
expect(answerSection).toBeDefined();
answerSection = answerSection as AccordionAnswerSection;
const { textField, resetButton } = answerSection;
expect(textField.outerHTML).toContain("Answer 2");
// change answer title
fireEvent.change(textField, {
target: { value: "New answer" },
});
await waitFor(() => {
expect(textField.outerHTML).toContain("New answer");
});
// reset and verify
fireEvent.click(resetButton);
await waitFor(() => {
expect(textField.outerHTML).toContain("Answer 2");
});
});
test("enables editing an answer", async () => {
renderQuestionAnswerList(editAnswerMock);
const questionAnswerCards = await waitForQuestionsToRender();
let answerSection = await expandAccordionAndGetAnswerSection(
questionAnswerCards[1]
);
expect(answerSection).toBeDefined();
answerSection = answerSection as AccordionAnswerSection;
const { textField, saveButton, resetButton } = answerSection;
expect(textField.outerHTML).toContain("Answer 2");
// change answer title
fireEvent.change(textField, {
target: { value: "New answer" },
});
await waitFor(() => {
expect(textField.outerHTML).toContain("New answer");
});
// call backend and assert apollo cache update (i.e a subsequent reset should reset to the new answer)
fireEvent.click(saveButton);
fireEvent.change(textField, {
target: { value: "something else" },
});
await waitFor(() => {
expect(textField.outerHTML).toContain("something else");
});
fireEvent.click(resetButton);
await waitFor(() => {
expect(textField.outerHTML).toContain("New answer");
});
});
test("enables adding an answer via setting the position", async () => {
renderQuestionAnswerList(addAnswerMock);
const questionAnswerCards = await waitForQuestionsToRender();
const questionWithoutAnswerCard = questionAnswerCards[2];
let answerSection = await expandAccordionAndGetAnswerSection(
questionWithoutAnswerCard
);
expect(answerSection).toBeDefined();
answerSection = answerSection as AccordionAnswerSection;
const { positionIconButtons } = answerSection;
expect(positionIconButtons.skipped.outerHTML).toContain(
'aria-pressed="true"'
);
expect(positionIconButtons.positive.outerHTML).toContain(
'aria-pressed="false"'
);
// press "positive" icon button and wait for accordion header to change -> apollo cache update successful
fireEvent.click(positionIconButtons.positive);
await waitFor(() => {
expect(questionAnswerCards[2].innerHTML).toContain(
getPositivePositionPath()
);
});
expect(positionIconButtons.skipped.outerHTML).toContain(
'aria-pressed="false"'
);
expect(positionIconButtons.positive.outerHTML).toContain(
'aria-pressed="true"'
);
});
});
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,
};
};

View file

@ -1,73 +1,75 @@
import React from 'react'; import React from "react";
import {render, screen} from '@testing-library/react' import { render, screen } from "@testing-library/react";
import {MockedProvider} from '@apollo/client/testing'; import { MockedProvider } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import App from "../App"; import App from "../App";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
const renderAppAtUrl = (path: string) => render( const renderAppAtUrl = (path: string) =>
<MockedProvider> render(
<MemoryRouter initialEntries={[path]}> <MockedProvider>
<SnackbarProvider> <MemoryRouter initialEntries={[path]}>
<App/> <SnackbarProvider>
</SnackbarProvider> <App />
</MemoryRouter> </SnackbarProvider>
</MockedProvider> </MemoryRouter>
); </MockedProvider>
);
beforeEach(() => localStorage.clear()) beforeEach(() => localStorage.clear());
describe('The root path /', () => { describe("The root path /", () => {
test("renders user's home page if they are logged in", () => {
test('renders user\'s home page if they are logged in', () => { localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem("token", "asdfasdfasdf")
renderAppAtUrl("/"); renderAppAtUrl("/");
expect(() => screen.getByLabelText(/current user/)).not.toThrow() expect(() => screen.getByLabelText(/current user/)).not.toThrow();
}); });
test('redirects to login page if user not logged in', () => { test("redirects to login page if user not logged in", () => {
renderAppAtUrl("/"); renderAppAtUrl("/");
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
expect(emailField).toHaveValue(""); expect(emailField).toHaveValue("");
expect(passwordField).toHaveValue(""); expect(passwordField).toHaveValue("");
}); });
}); });
describe('The /login path', () => { describe("The /login path", () => {
test('renders the signin page if the user is not logged in', () => { test("renders the signin page if the user is not logged in", () => {
renderAppAtUrl("/login"); renderAppAtUrl("/login");
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
expect(emailField).toHaveValue(""); expect(emailField).toHaveValue("");
expect(passwordField).toHaveValue(""); expect(passwordField).toHaveValue("");
}); });
test('redirects to root / and the user\'s home page if the user is logged in', () => { test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf") localStorage.setItem("token", "asdfasdfasdf");
renderAppAtUrl("/login"); renderAppAtUrl("/login");
expect(() => screen.getByLabelText(/current user/)).not.toThrow() expect(() => screen.getByLabelText(/current user/)).not.toThrow();
}); });
}); });
describe('The /signup path', () => { describe("The /signup path", () => {
test('renders the signup page if the user is not logged in', () => { test("renders the signup page if the user is not logged in", () => {
renderAppAtUrl("/signup"); renderAppAtUrl("/signup");
expect(() => screen.getByRole('textbox', {name: 'Email Address'})).not.toThrow() expect(() =>
expect(() => screen.getByLabelText(/Password/)).not.toThrow() screen.getByRole("textbox", { name: "Email Address" })
expect(() => screen.getByLabelText(/First Name/)).not.toThrow() ).not.toThrow();
expect(() => screen.getByLabelText(/Last Name/)).not.toThrow() expect(() => screen.getByLabelText(/Password/)).not.toThrow();
expect(() => screen.getByLabelText(/First Name/)).not.toThrow();
expect(() => screen.getByLabelText(/Last Name/)).not.toThrow();
}); });
test('redirects to root / and the user\'s home page if the user is logged in', () => { test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf") localStorage.setItem("token", "asdfasdfasdf");
renderAppAtUrl("/signup"); renderAppAtUrl("/signup");
expect(() => screen.getByLabelText(/current user/)).not.toThrow() expect(() => screen.getByLabelText(/current user/)).not.toThrow();
}); });
}); });

View file

@ -1,71 +1,86 @@
import React from 'react'; import React from "react";
import {fireEvent, render, screen, waitFor} from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {MockedProvider, MockedResponse} from '@apollo/client/testing'; import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import CategoryList from "../components/CategoryList"; import CategoryList from "../components/CategoryList";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
import {categoryNodesMock, getAllCategoriesMock, getCategoryByIdMock} from "../backend/queries/category.mock"; import {
import {addCategoryMock, deleteCategoryMock, editCategoryMock} from "../backend/mutations/category.mock"; categoryNodesMock,
import {expandAccordionAndGetIconButtons, queryAllAddIconButtons, queryAllEditIconButtons} from "./test-helper"; getAllCategoriesMock,
getCategoryByIdMock,
} from "../backend/queries/category.mock";
import {
addCategoryMock,
deleteCategoryMock,
editCategoryMock,
} from "../backend/mutations/category.mock";
import {
expandAccordionAndGetIconButtons,
queryAllAddIconButtons,
queryAllEditIconButtons,
} from "./test-helper";
describe("The CategoryList", () => {
describe('The CategoryList', () => { test("displays the existing categories, but not the details of it", async () => {
test('displays the existing categories, but not the details of it', async () => {
renderCategoryList(); renderCategoryList();
const categoryCards = await waitForInitialCategoriesToRender() const categoryCards = await waitForInitialCategoriesToRender();
categoryCards.forEach(card => { categoryCards.forEach((card) => {
expect(card.innerHTML).toMatch(/Category [1-2]/) expect(card.innerHTML).toMatch(/Category [1-2]/);
}) });
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
test('enables toggling details on each category', async () => { test("enables toggling details on each category", async () => {
renderCategoryList(); renderCategoryList();
// Initial state: Every category card is not expanded // Initial state: Every category card is not expanded
const categoryCards = await waitForInitialCategoriesToRender() const categoryCards = await waitForInitialCategoriesToRender();
// Expand first category card // Expand first category card
await expandAccordionAndGetIconButtons(categoryCards[0]) await expandAccordionAndGetIconButtons(categoryCards[0]);
// Shrink first category card again // Shrink first category card again
fireEvent.click(categoryCards[0]) fireEvent.click(categoryCards[0]);
await waitFor(() => { await waitFor(() => {
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
}); });
test('enables editing a category title', async () => { test("enables editing a category title", async () => {
renderCategoryList(editCategoryMock); renderCategoryList(editCategoryMock);
const categoryCards = await waitForInitialCategoriesToRender(); const categoryCards = await waitForInitialCategoriesToRender();
const {editIconButton} = await expandAccordionAndGetIconButtons(categoryCards[0]); const { editIconButton } = await expandAccordionAndGetIconButtons(
categoryCards[0]
);
// open edit dialog // open edit dialog
expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull(); expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull();
fireEvent.click(editIconButton); fireEvent.click(editIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie bearbeiten/)).not.toBeNull(); expect(screen.queryByText(/Kategorie bearbeiten/)).not.toBeNull();
}) });
// change category title // change category title
const categoryTitleField = screen.getByDisplayValue(/Category 1/); const categoryTitleField = screen.getByDisplayValue(/Category 1/);
fireEvent.change(categoryTitleField, {target: {value: "New title for Category 1"}}); fireEvent.change(categoryTitleField, {
target: { value: "New title for Category 1" },
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull(); expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Speichern/}); const confirmButton = screen.getByRole("button", { name: /Speichern/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull(); expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull();
expect(screen.queryByText(/New title for Category 1/)).not.toBeNull() expect(screen.queryByText(/New title for Category 1/)).not.toBeNull();
}) });
}); });
test('enables adding a category', async () => { test("enables adding a category", async () => {
renderCategoryList(addCategoryMock); renderCategoryList(addCategoryMock);
await waitForInitialCategoriesToRender(); await waitForInitialCategoriesToRender();
@ -76,69 +91,74 @@ describe('The CategoryList', () => {
fireEvent.click(addButton); fireEvent.click(addButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).not.toBeNull(); expect(screen.queryByText(dialogIdentifier)).not.toBeNull();
}) });
// change category title // change category title
const categoryTitleField = screen.getByLabelText(/Zusammenfassung/); const categoryTitleField = screen.getByLabelText(/Zusammenfassung/);
fireEvent.change(categoryTitleField, {target: {value: "New category"}}); fireEvent.change(categoryTitleField, { target: { value: "New category" } });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New category/)).not.toBeNull(); expect(screen.queryByDisplayValue(/New category/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Erstellen/}); const confirmButton = screen.getByRole("button", { name: /Erstellen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).toBeNull(); expect(screen.queryByText(dialogIdentifier)).toBeNull();
expect(screen.queryByText(/New category/)).not.toBeNull() expect(screen.queryByText(/New category/)).not.toBeNull();
}) });
}); });
test('enables deleting a category', async () => { test("enables deleting a category", async () => {
renderCategoryList(deleteCategoryMock); renderCategoryList(deleteCategoryMock);
const categoryCards = await waitForInitialCategoriesToRender(); const categoryCards = await waitForInitialCategoriesToRender();
expect(screen.queryByText(/Category 2/)).not.toBeNull(); expect(screen.queryByText(/Category 2/)).not.toBeNull();
const {deleteIconButton} = await expandAccordionAndGetIconButtons(categoryCards[1]); const { deleteIconButton } = await expandAccordionAndGetIconButtons(
categoryCards[1]
);
// open delete confirmation dialog // open delete confirmation dialog
expect(screen.queryByText(/Kategorie löschen/)).toBeNull(); expect(screen.queryByText(/Kategorie löschen/)).toBeNull();
fireEvent.click(deleteIconButton); fireEvent.click(deleteIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie löschen/)).not.toBeNull(); expect(screen.queryByText(/Kategorie löschen/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Löschen/}); const confirmButton = screen.getByRole("button", { name: /Löschen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie löschen/)).toBeNull(); expect(screen.queryByText(/Kategorie löschen/)).toBeNull();
expect(screen.queryByText(/Category 2/)).toBeNull(); expect(screen.queryByText(/Category 2/)).toBeNull();
}) });
}); });
}); });
function renderCategoryList(additionalMocks?: Array<MockedResponse>) { function renderCategoryList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock]; const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render( return render(
<MockedProvider mocks={allMocks}> <MockedProvider mocks={allMocks}>
<MemoryRouter> <MemoryRouter>
<SnackbarProvider> <SnackbarProvider>
<CategoryList/> <CategoryList />
</SnackbarProvider> </SnackbarProvider>
</MemoryRouter> </MemoryRouter>
</MockedProvider> </MockedProvider>
); );
} }
const waitForInitialCategoriesToRender = async (): Promise<Array<HTMLElement>> => { const waitForInitialCategoriesToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length; const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = []; let categoryCards: Array<HTMLElement> = [];
await waitFor(() => { await waitFor(() => {
categoryCards = screen.queryAllByRole("button", {name: /Category [1-2]/}) categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery); expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
}); });
return categoryCards; return categoryCards;
} };

View file

@ -1,77 +1,88 @@
import React from 'react'; import React from "react";
import {fireEvent, render, screen, waitFor} from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {MockedProvider, MockedResponse} from '@apollo/client/testing'; import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import QuestionList from "../components/QuestionList"; import QuestionList from "../components/QuestionList";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
import {getAllQuestionsMock, getQuestionByIdMock, questionNodesMock} from "../backend/queries/question.mock"; import {
import {getAllCategoriesMock} from "../backend/queries/category.mock"; getAllQuestionsMock,
import {addQuestionMock, deleteQuestionMock, editQuestionMock} from "../backend/mutations/question.mock"; getQuestionByIdMock,
questionNodesMock,
} from "../backend/queries/question.mock";
import { getAllCategoriesMock } from "../backend/queries/category.mock";
import {
addQuestionMock,
deleteQuestionMock,
editQuestionMock,
} from "../backend/mutations/question.mock";
import { import {
expandAccordionAndGetIconButtons, expandAccordionAndGetIconButtons,
queryAllAddIconButtons, queryAllAddIconButtons,
queryAllEditIconButtons queryAllEditIconButtons,
} from "./test-helper"; } from "./test-helper";
describe("The QuestionList", () => {
describe('The QuestionList', () => { test("displays the existing questions, but not the details of it", async () => {
test('displays the existing questions, but not the details of it', async () => {
renderQuestionList(); renderQuestionList();
const questionCards = await waitForInitialQuestionsToRender() const questionCards = await waitForInitialQuestionsToRender();
questionCards.forEach(card => { questionCards.forEach((card) => {
expect(card.innerHTML).toMatch(/Question [1-3]\?/) expect(card.innerHTML).toMatch(/Question [1-3]\?/);
}) });
expect(questionCards[0].innerHTML).toMatch(/Category 1/); expect(questionCards[0].innerHTML).toMatch(/Category 1/);
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
test('enables toggling details on each question', async () => { test("enables toggling details on each question", async () => {
renderQuestionList(); renderQuestionList();
// Initial state: Every question card is not expanded // Initial state: Every question card is not expanded
const questionCards = await waitForInitialQuestionsToRender() const questionCards = await waitForInitialQuestionsToRender();
// Expand first question card // Expand first question card
await expandAccordionAndGetIconButtons(questionCards[0]) await expandAccordionAndGetIconButtons(questionCards[0]);
// Shrink first question card again // Shrink first question card again
fireEvent.click(questionCards[0]) fireEvent.click(questionCards[0]);
await waitFor(() => { await waitFor(() => {
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
}); });
test('enables editing a question title', async () => { test("enables editing a question title", async () => {
renderQuestionList(editQuestionMock); renderQuestionList(editQuestionMock);
const questionCards = await waitForInitialQuestionsToRender(); const questionCards = await waitForInitialQuestionsToRender();
const {editIconButton} = await expandAccordionAndGetIconButtons(questionCards[0]); const { editIconButton } = await expandAccordionAndGetIconButtons(
questionCards[0]
);
// open edit dialog // open edit dialog
expect(screen.queryByText(/Frage bearbeiten/)).toBeNull(); expect(screen.queryByText(/Frage bearbeiten/)).toBeNull();
fireEvent.click(editIconButton); fireEvent.click(editIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage bearbeiten/)).not.toBeNull(); expect(screen.queryByText(/Frage bearbeiten/)).not.toBeNull();
}) });
// change question title // change question title
const questionTitleField = screen.getByDisplayValue(/Question 1/); const questionTitleField = screen.getByDisplayValue(/Question 1/);
fireEvent.change(questionTitleField, {target: {value: "New title for Question 1?"}}); fireEvent.change(questionTitleField, {
target: { value: "New title for Question 1?" },
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull(); expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Speichern/}); const confirmButton = screen.getByRole("button", { name: /Speichern/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage bearbeiten/)).toBeNull(); expect(screen.queryByText(/Frage bearbeiten/)).toBeNull();
expect(screen.queryByText(/New title for Question 1/)).not.toBeNull() expect(screen.queryByText(/New title for Question 1/)).not.toBeNull();
}) });
}); });
test('enables adding a question', async () => { test("enables adding a question", async () => {
renderQuestionList(addQuestionMock); renderQuestionList(addQuestionMock);
await waitForInitialQuestionsToRender(); await waitForInitialQuestionsToRender();
@ -82,68 +93,82 @@ describe('The QuestionList', () => {
fireEvent.click(addButton); fireEvent.click(addButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).not.toBeNull(); expect(screen.queryByText(dialogIdentifier)).not.toBeNull();
}) });
// change question title // change question title
const questionTitleField = screen.getByLabelText(/Zusammenfassung/); const questionTitleField = screen.getByLabelText(/Zusammenfassung/);
fireEvent.change(questionTitleField, {target: {value: "New question?"}}); fireEvent.change(questionTitleField, {
target: { value: "New question?" },
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New question/)).not.toBeNull(); expect(screen.queryByDisplayValue(/New question/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Erstellen/}); const confirmButton = screen.getByRole("button", { name: /Erstellen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).toBeNull(); expect(screen.queryByText(dialogIdentifier)).toBeNull();
expect(screen.queryByText(/New question/)).not.toBeNull() expect(screen.queryByText(/New question/)).not.toBeNull();
}) });
}); });
test('enables deleting a question', async () => { test("enables deleting a question", async () => {
renderQuestionList(deleteQuestionMock); renderQuestionList(deleteQuestionMock);
const questionCards = await waitForInitialQuestionsToRender(); const questionCards = await waitForInitialQuestionsToRender();
expect(screen.queryByText(/Question 2/)).not.toBeNull(); expect(screen.queryByText(/Question 2/)).not.toBeNull();
const {deleteIconButton} = await expandAccordionAndGetIconButtons(questionCards[1]); const { deleteIconButton } = await expandAccordionAndGetIconButtons(
questionCards[1]
);
// open delete confirmation dialog // open delete confirmation dialog
expect(screen.queryByText(/Frage löschen/)).toBeNull(); expect(screen.queryByText(/Frage löschen/)).toBeNull();
fireEvent.click(deleteIconButton); fireEvent.click(deleteIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage löschen/)).not.toBeNull(); expect(screen.queryByText(/Frage löschen/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Löschen/}); const confirmButton = screen.getByRole("button", { name: /Löschen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage löschen/)).toBeNull(); expect(screen.queryByText(/Frage löschen/)).toBeNull();
expect(screen.queryByText(/Question 2/)).toBeNull(); expect(screen.queryByText(/Question 2/)).toBeNull();
}) });
}); });
}); });
function renderQuestionList(additionalMocks?: Array<MockedResponse>) { function renderQuestionList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllQuestionsMock, ...getQuestionByIdMock, ...getAllCategoriesMock]; const initialMocks = [
const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks ...getAllQuestionsMock,
...getQuestionByIdMock,
...getAllCategoriesMock,
];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render( return render(
<MockedProvider mocks={allMocks}> <MockedProvider mocks={allMocks}>
<MemoryRouter> <MemoryRouter>
<SnackbarProvider> <SnackbarProvider>
<QuestionList/> <QuestionList />
</SnackbarProvider> </SnackbarProvider>
</MemoryRouter> </MemoryRouter>
</MockedProvider> </MockedProvider>
); );
} }
const waitForInitialQuestionsToRender = async (): Promise<Array<HTMLElement>> => { const waitForInitialQuestionsToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfQuestionsInMockQuery = questionNodesMock.length; const numberOfQuestionsInMockQuery = questionNodesMock.length;
let questionCards: Array<HTMLElement> = []; let questionCards: Array<HTMLElement> = [];
await waitFor(() => { await waitFor(() => {
questionCards = screen.queryAllByRole("button", {name: /Question [1-3]\?/}) questionCards = screen.queryAllByRole("button", {
name: /Question [1-3]\?/,
});
expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery); expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
}); });
return questionCards; return questionCards;
} };

View file

@ -1,48 +1,60 @@
import React from 'react'; import React from "react";
import SignIn from "../components/SignIn"; import SignIn from "../components/SignIn";
import {fireEvent, render, screen, waitFor} from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {MockedProvider} from '@apollo/client/testing'; import { MockedProvider } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import {loginMock} from "../backend/mutations/login.mock"; import { loginMock } from "../backend/mutations/login.mock";
const mockHistoryReplace = jest.fn(); const mockHistoryReplace = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock("react-router-dom", () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual("react-router-dom"),
useHistory: () => ({ useHistory: () => ({
replace: mockHistoryReplace, replace: mockHistoryReplace,
}), }),
})); }));
describe('SignIn page', () => { describe("SignIn page", () => {
beforeEach(() => mockHistoryReplace.mockReset()) beforeEach(() => mockHistoryReplace.mockReset());
test('initial state', () => { test("initial state", () => {
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>); render(
<MockedProvider mocks={loginMock}>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</MockedProvider>
);
// it renders empty email and passsword fields // it renders empty email and passsword fields
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
expect(emailField).toHaveValue(''); expect(emailField).toHaveValue("");
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
expect(passwordField).toHaveValue(''); expect(passwordField).toHaveValue("");
// it renders enabled submit button // it renders enabled submit button
const button = screen.getByRole('button'); const button = screen.getByRole("button");
expect(button).not.toBeDisabled(); expect(button).not.toBeDisabled();
expect(button).toHaveTextContent('Sign In'); expect(button).toHaveTextContent("Sign In");
expect(mockHistoryReplace).not.toHaveBeenCalled(); expect(mockHistoryReplace).not.toHaveBeenCalled();
}); });
test('successful login', async () => { test("successful login", async () => {
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>); render(
<MockedProvider mocks={loginMock}>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</MockedProvider>
);
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
const button = screen.getByRole('button', {name: /sign in/i}); const button = screen.getByRole("button", { name: /sign in/i });
// fill out and submit form // fill out and submit form
fireEvent.change(emailField, {target: {value: 'test@email.com'}}); fireEvent.change(emailField, { target: { value: "test@email.com" } });
fireEvent.change(passwordField, {target: {value: 'password'}}); fireEvent.change(passwordField, { target: { value: "password" } });
fireEvent.click(button); fireEvent.click(button);
@ -51,22 +63,28 @@ describe('SignIn page', () => {
}); });
}); });
test('error login', async () => { test("error login", async () => {
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>); render(
<MockedProvider mocks={loginMock}>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</MockedProvider>
);
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
const button = screen.getByRole('button'); const button = screen.getByRole("button");
// fill out and submit form // fill out and submit form
fireEvent.change(emailField, {target: {value: 'test@email.com'}}); fireEvent.change(emailField, { target: { value: "test@email.com" } });
fireEvent.change(passwordField, {target: {value: 'wrong-password'}}); fireEvent.change(passwordField, { target: { value: "wrong-password" } });
fireEvent.click(button); fireEvent.click(button);
await waitFor(() => { await waitFor(() => {
// it resets button // it resets button
expect(button).not.toBeDisabled(); expect(button).not.toBeDisabled();
expect(button).toHaveTextContent('Sign In'); expect(button).toHaveTextContent("Sign In");
// it displays error text // it displays error text
const errorText = screen.getByText(/Wrong username or password/); const errorText = screen.getByText(/Wrong username or password/);

View file

@ -1,50 +1,110 @@
import React from 'react'; import React from "react";
import {fireEvent, queryAllByRole, render, screen, waitFor} from '@testing-library/react' import {
import EditIcon from '@material-ui/icons/Edit'; fireEvent,
import DeleteIcon from '@material-ui/icons/Delete'; queryAllByRole,
import AddIcon from '@material-ui/icons/Add'; render,
screen,
waitFor,
} from "@testing-library/react";
import EditIcon from "@material-ui/icons/Edit";
import DeleteIcon from "@material-ui/icons/Delete";
import AddIcon from "@material-ui/icons/Add";
import {
CandidatePosition,
getIconForPosition,
} from "../components/CandidatePositionLegend";
const memoizedGetIconPath = (icon: JSX.Element) => { const memoizedGetIconPath = (icon: JSX.Element) => {
let cache: { path?: string } = {}; const cache: { path?: string } = {};
return (): string => { return (): string => {
if (cache?.path) { if (cache?.path) {
return cache.path return cache.path;
} else { } else {
const {container} = render(icon) const { container } = render(icon);
const path = container.innerHTML.match(/<path d="(.*)">/)?.[1] const path = container.innerHTML.match(/<path d="(.*)">/)?.[1];
if (!path) { if (!path) {
throw `Could not get path of MUI ${icon.type.displayName}` throw `Could not get path of MUI ${icon.type.displayName}`;
} }
cache.path = path cache.path = path;
return path return path;
} }
} };
} };
const getEditIconPath = memoizedGetIconPath(<EditIcon/>) const getEditIconPath = memoizedGetIconPath(<EditIcon />);
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon/>) const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon />);
const getAddIconPath = memoizedGetIconPath(<AddIcon/>) const getAddIconPath = memoizedGetIconPath(<AddIcon />);
export const getPositivePositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.positive)
);
export const getNeutralPositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.neutral)
);
export const getNegativePositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.negative)
);
export const getSkippedPositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.skipped)
);
export const queryAllIconButtons = (
iconPath: string,
container?: HTMLElement
): HTMLElement[] => {
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... // sorry, I found no better way to find a specific icon button...
export const queryAllEditIconButtons = (container?: HTMLElement): Array<HTMLElement> => { export const queryAllEditIconButtons = (
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) container?: HTMLElement
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getEditIconPath())); ): Array<HTMLElement> => {
} return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getEditIconPath())
);
};
// sorry, I found no better way to find a specific icon button... // sorry, I found no better way to find a specific icon button...
const queryAllDeleteIconButtons = (container?: HTMLElement): Array<HTMLElement> => { const queryAllDeleteIconButtons = (
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) container?: HTMLElement
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getDeleteIconPath())); ): Array<HTMLElement> => {
} return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getDeleteIconPath())
);
};
// sorry, I found no better way to find a specific icon button... // sorry, I found no better way to find a specific icon button...
export const queryAllAddIconButtons = (container?: HTMLElement): Array<HTMLElement> => { export const queryAllAddIconButtons = (
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) container?: HTMLElement
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getAddIconPath())); ): Array<HTMLElement> => {
} return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getAddIconPath())
);
};
export const expandAccordionAndGetIconButtons = async (accordion: HTMLElement): Promise<{ editIconButton: HTMLElement, deleteIconButton: HTMLElement }> => { export const expandAccordionAndGetIconButtons = async (
accordion: HTMLElement
): Promise<{ editIconButton: HTMLElement; deleteIconButton: HTMLElement }> => {
let editIconsButtons = queryAllDeleteIconButtons(); let editIconsButtons = queryAllDeleteIconButtons();
let deleteIconsButtons = queryAllEditIconButtons(); let deleteIconsButtons = queryAllEditIconButtons();
expect(editIconsButtons).toHaveLength(0); expect(editIconsButtons).toHaveLength(0);
@ -55,9 +115,9 @@ export const expandAccordionAndGetIconButtons = async (accordion: HTMLElement):
deleteIconsButtons = queryAllDeleteIconButtons(); deleteIconsButtons = queryAllDeleteIconButtons();
expect(editIconsButtons).toHaveLength(1); expect(editIconsButtons).toHaveLength(1);
expect(deleteIconsButtons).toHaveLength(1); expect(deleteIconsButtons).toHaveLength(1);
}) });
return { return {
editIconButton: editIconsButtons[0], editIconButton: editIconsButtons[0],
deleteIconButton: deleteIconsButtons[0] deleteIconButton: deleteIconsButtons[0],
}; };
} };

View file

@ -1,10 +1,11 @@
import {parseJwt} from "./jwt"; import { parseJwt } from "./jwt";
describe("The parseJwt function", () => { describe("The parseJwt function", () => {
test("parses a valid candymat jwt", () => { test("parses a valid candymat jwt", () => {
const validJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M"; const validJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M";
const jwt = parseJwt(validJwt) const jwt = parseJwt(validJwt);
expect(jwt).not.toBeNull(); expect(jwt).not.toBeNull();
expect(jwt?.person_row_id).toBe(1); expect(jwt?.person_row_id).toBe(1);
@ -12,25 +13,28 @@ describe("The parseJwt function", () => {
}); });
test("returns null if role claim is invalid", () => { test("returns null if role claim is invalid", () => {
const invalidRoleClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHllZGl0b3IiLCJwZXJzb25fcm93X2lkIjoxLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0._AVFTMqMkIuyrfQGTmWE-Qi-C72KCrZ3s_uVyfuEDco"; const invalidRoleClaimJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHllZGl0b3IiLCJwZXJzb25fcm93X2lkIjoxLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0._AVFTMqMkIuyrfQGTmWE-Qi-C72KCrZ3s_uVyfuEDco";
const jwt = parseJwt(invalidRoleClaimJwt); const jwt = parseJwt(invalidRoleClaimJwt);
expect(jwt).toBeNull(); expect(jwt).toBeNull();
}) });
test("returns null if person_row_id is not a number", () => { test("returns null if person_row_id is not a number", () => {
const invalidRowIdClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6IjEiLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0.NfXylzN44qrZA5DX0qxxU71vJ1o9gdunscnK6V193Fc"; const invalidRowIdClaimJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6IjEiLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0.NfXylzN44qrZA5DX0qxxU71vJ1o9gdunscnK6V193Fc";
const jwt = parseJwt(invalidRowIdClaimJwt); const jwt = parseJwt(invalidRowIdClaimJwt);
expect(jwt).toBeNull(); expect(jwt).toBeNull();
}) });
test("returns null if token is rubish.", () => { test("returns null if token is rubish.", () => {
const brokenJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eysssssssssssssssssssss.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M"; const brokenJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eysssssssssssssssssssss.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M";
const jwt = parseJwt(brokenJwt); const jwt = parseJwt(brokenJwt);
expect(jwt).toBeNull(); expect(jwt).toBeNull();
}) });
}); });

View file

@ -1,48 +1,49 @@
export const getRawJsonWebToken = (): string | null => { export const getRawJsonWebToken = (): string | null => {
return localStorage.getItem('token'); return localStorage.getItem("token");
} };
export const getJsonWebToken = (): JwtPayload | null => { export const getJsonWebToken = (): JwtPayload | null => {
const rawToken = getRawJsonWebToken(); const rawToken = getRawJsonWebToken();
return rawToken ? parseJwt(rawToken) : null return rawToken ? parseJwt(rawToken) : null;
} };
export const parseJwt = (token: string): JwtPayload | null => { export const parseJwt = (token: string): JwtPayload | null => {
try { try {
const base64Url = token.split('.')[1]; const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent( const jsonPayload = decodeURIComponent(
atob(base64) atob(base64)
.split('') .split("")
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join('') .join("")
); );
const jwtPayload = JSON.parse(jsonPayload); const jwtPayload = JSON.parse(jsonPayload);
return isJwtPayloadValid(jwtPayload) ? jwtPayload : null return isJwtPayloadValid(jwtPayload) ? jwtPayload : null;
} catch { } catch {
return null return null;
} }
} };
export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => { export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => {
return claims.every(claim => Object.keys(jwtPayload).includes(claim)) return (
&& userRoles.includes(jwtPayload.role) claims.every((claim) => Object.keys(jwtPayload).includes(claim)) &&
&& typeof (jwtPayload.person_row_id) === 'number' userRoles.includes(jwtPayload.role) &&
&& typeof (jwtPayload.exp) === 'number' typeof jwtPayload.person_row_id === "number" &&
&& typeof (jwtPayload.iat) === 'number'; typeof jwtPayload.exp === "number" &&
} typeof jwtPayload.iat === "number"
);
};
const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"] const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"];
const userRoles = ["candymat_editor", 'candymat_candidate', 'candymat_person'] const userRoles = ["candymat_editor", "candymat_candidate", "candymat_person"];
interface JwtPayload { interface JwtPayload {
"role": UserRole, role: UserRole;
"person_row_id": number, person_row_id: number;
"exp": number, exp: number;
"iat": number, iat: number;
"aud": "postgraphile", aud: "postgraphile";
"iss": "postgraphile" iss: "postgraphile";
} }
type UserRole = "candymat_editor" | 'candymat_candidate' | 'candymat_person' type UserRole = "candymat_editor" | "candymat_candidate" | "candymat_person";

View file

@ -11,9 +11,9 @@
// opt-in, read https://bit.ly/CRA-PWA // opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean( const isLocalhost = Boolean(
window.location.hostname === 'localhost' || window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address. // [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' || window.location.hostname === "[::1]" ||
// 127.0.0.0/8 are considered localhost for IPv4. // 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match( window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
@ -25,13 +25,10 @@ type Config = {
onUpdate?: (registration: ServiceWorkerRegistration) => void; onUpdate?: (registration: ServiceWorkerRegistration) => void;
}; };
export function register(config?: Config) { export function register(config?: Config): void {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL( const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
process.env.PUBLIC_URL,
window.location.href
);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different 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 // from what our page is served on. This might happen if a CDN is used to
@ -39,7 +36,7 @@ export function register(config?: Config) {
return; return;
} }
window.addEventListener('load', () => { window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) { if (isLocalhost) {
@ -50,8 +47,8 @@ export function register(config?: Config) {
// service worker/PWA documentation. // service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker.ready.then(() => {
console.log( console.log(
'This web app is being served cache-first by a service ' + "This web app is being served cache-first by a service " +
'worker. To learn more, visit https://bit.ly/CRA-PWA' "worker. To learn more, visit https://bit.ly/CRA-PWA"
); );
}); });
} else { } else {
@ -65,21 +62,21 @@ export function register(config?: Config) {
function registerValidSW(swUrl: string, config?: Config) { function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then(registration => { .then((registration) => {
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
if (installingWorker == null) { if (installingWorker == null) {
return; return;
} }
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') { if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched, // At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older // but the previous service worker will still serve the older
// content until all client tabs are closed. // content until all client tabs are closed.
console.log( console.log(
'New content is available and will be used when all ' + "New content is available and will be used when all " +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.' "tabs for this page are closed. See https://bit.ly/CRA-PWA."
); );
// Execute callback // Execute callback
@ -90,7 +87,7 @@ function registerValidSW(swUrl: string, config?: Config) {
// At this point, everything has been precached. // At this point, everything has been precached.
// It's the perfect time to display a // It's the perfect time to display a
// "Content is cached for offline use." message. // "Content is cached for offline use." message.
console.log('Content is cached for offline use.'); console.log("Content is cached for offline use.");
// Execute callback // Execute callback
if (config && config.onSuccess) { if (config && config.onSuccess) {
@ -101,25 +98,25 @@ function registerValidSW(swUrl: string, config?: Config) {
}; };
}; };
}) })
.catch(error => { .catch((error) => {
console.error('Error during service worker registration:', error); console.error("Error during service worker registration:", error);
}); });
} }
function checkValidServiceWorker(swUrl: string, config?: Config) { function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, { fetch(swUrl, {
headers: { 'Service-Worker': 'script' } headers: { "Service-Worker": "script" },
}) })
.then(response => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type'); const contentType = response.headers.get("content-type");
if ( if (
response.status === 404 || response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1) (contentType != null && contentType.indexOf("javascript") === -1)
) { ) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload();
}); });
@ -131,18 +128,18 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
}) })
.catch(() => { .catch(() => {
console.log( console.log(
'No internet connection found. App is running in offline mode.' "No internet connection found. App is running in offline mode."
); );
}); });
} }
export function unregister() { export function unregister(): void {
if ('serviceWorker' in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then(registration => { .then((registration) => {
registration.unregister(); registration.unregister();
}) })
.catch(error => { .catch((error) => {
console.error(error.message); console.error(error.message);
}); });
} }

View file

@ -2,4 +2,4 @@
// allows you to do things like: // allows you to do things like:
// expect(element).toHaveTextContent(/react/i) // expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom // learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect'; import "@testing-library/jest-dom/extend-expect";

View file

@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -19,7 +15,5 @@
"noEmit": true, "noEmit": true,
"jsx": "react" "jsx": "react"
}, },
"include": [ "include": ["src"]
"src"
]
} }