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,
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),
created_at timestamp default now(),
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
* Install `npm`
* In this directory run `npm ci` to install all dependencies according to the package.json and package-lock.json.
- Install `npm`
- In this directory run `npm ci` to install all dependencies according to the package.json and package-lock.json.
### Develop locally
* In the parent directory run
- In the parent directory run
```shell script
docker-compose up
```
which will start the whole setup including this app in a dockerfile.
However, rebuilding and restarting this image can be cumbersome and is not necessary in the development setup.
* Instead run
- Instead run
```shell script
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-dom": "^16.9.8",
"@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",
"jest-environment-jsdom-sixteen": "^1.0.3"
"jest-environment-jsdom-sixteen": "^1.0.3",
"lint-staged": "^10.5.3",
"prettier": "2.2.1"
},
"scripts": {
"start": "react-scripts start",
@ -38,9 +44,15 @@
},
"husky": {
"hooks": {
"pre-commit": "npm test"
"pre-commit": "lint-staged && npm test"
}
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint",
"prettier --write"
]
},
"browserslist": {
"production": [
">0.2%",

View File

@ -8,7 +8,6 @@
<meta
name="description"
content="App zum Erstellen von Fragen für den Candymat"
/>
<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 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 SignUp from "./components/SignUp";
function App() {
function App(): React.ReactElement {
return (
<Switch>
<PrivateRoute exact path={"/"}><Main /></PrivateRoute>
<NotLoggedInOnlyRoute path={"/login"}><SignIn /></NotLoggedInOnlyRoute>
<NotLoggedInOnlyRoute path={"/signup"}><SignUp /></NotLoggedInOnlyRoute>
<PrivateRoute exact path={"/"}>
<Main />
</PrivateRoute>
<NotLoggedInOnlyRoute path={"/login"}>
<SignIn />
</NotLoggedInOnlyRoute>
<NotLoggedInOnlyRoute path={"/signup"}>
<SignUp />
</NotLoggedInOnlyRoute>
</Switch>
)
);
}
export const isLoggedIn = () => !!localStorage.getItem("token")
export const isLoggedIn = (): boolean => !!localStorage.getItem("token");
// @ts-ignore
function PrivateRoute({ children, ...rest }) {
function PrivateRoute({ children, ...rest }: RouteProps) {
return (
<Route
{...rest}
@ -30,7 +34,7 @@ function PrivateRoute({ children, ...rest }) {
<Redirect
to={{
pathname: "/login",
state: { from: location }
state: { from: location },
}}
/>
)
@ -39,12 +43,11 @@ function PrivateRoute({ children, ...rest }) {
);
}
// @ts-ignore
function NotLoggedInOnlyRoute({ children, ...rest }) {
function NotLoggedInOnlyRoute({ children, ...rest }: RouteProps) {
return (
<Route
{...rest}
render={({ location }) =>
render={() =>
!isLoggedIn() ? (
children
) : (

View File

@ -1,20 +1,21 @@
import {ApolloClient, createHttpLink, InMemoryCache} from "@apollo/client";
import {setContext} from "@apollo/client/link/context";
import {getRawJsonWebToken} from "../jwt/jwt";
import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { getRawJsonWebToken } from "../jwt/jwt";
const httpLink = createHttpLink({
uri: 'http://localhost:5433/graphql',
uri: "http://localhost:5433/graphql",
});
const authLink = setContext((_, { headers }) => {
const token = getRawJsonWebToken();
return token ? {
headers: {
...headers,
authorization: `Bearer ${token}`,
}
} : headers
return token
? {
headers: {
...headers,
authorization: `Bearer ${token}`,
},
}
: headers;
});
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 {
ADD_CATEGORY, AddCategoryResponse,
AddCategoryVariables, DELETE_CATEGORY, DeleteCategoryPayload, DeleteCategoryResponse, DeleteCategoryVariables,
EDIT_CATEGORY, EditCategoryPayload,
ADD_CATEGORY,
AddCategoryResponse,
AddCategoryVariables,
DELETE_CATEGORY,
DeleteCategoryPayload,
DeleteCategoryResponse,
DeleteCategoryVariables,
EDIT_CATEGORY,
EditCategoryPayload,
EditCategoryResponse,
EditCategoryVariables
EditCategoryVariables,
} from "./category";
import {BasicCategoryResponse} from "../queries/category";
import {categoryNodesMock} from "../queries/category.mock";
import { BasicCategoryResponse } from "../queries/category";
import { categoryNodesMock } from "../queries/category.mock";
const editCategoryVariables: EditCategoryVariables = {
id: 'c1',
title: 'New title for Category 1',
description: 'Further information for C1',
id: "c1",
title: "New title for Category 1",
description: "Further information for C1",
};
const getEditedCategoryMock = (): EditCategoryPayload | null => {
const originalCategory = categoryNodesMock.find(c => c.id === editCategoryVariables.id)
return originalCategory ? {
category: {
...originalCategory,
title: editCategoryVariables.title === undefined ? originalCategory.title : editCategoryVariables.title,
description: editCategoryVariables.description === undefined ? originalCategory.description : null,
},
__typename: "UpdateCategoryPayload",
} : null
}
const originalCategory = categoryNodesMock.find(
(c) => c.id === editCategoryVariables.id
);
return originalCategory
? {
category: {
...originalCategory,
title:
editCategoryVariables.title === undefined
? originalCategory.title
: editCategoryVariables.title,
description:
editCategoryVariables.description === undefined
? originalCategory.description
: null,
},
__typename: "UpdateCategoryPayload",
}
: null;
};
export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [
{
@ -37,13 +52,13 @@ export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [
result: {
data: {
updateCategory: getEditedCategoryMock(),
}
},
},
},
]
];
const addCategoryVariables: AddCategoryVariables = {
title: 'New category',
title: "New category",
description: "",
};
@ -52,8 +67,8 @@ const addedCategoryMock: BasicCategoryResponse = {
rowId: 3,
title: addCategoryVariables.title as string,
description: addCategoryVariables.description as string,
__typename: "Category"
}
__typename: "Category",
};
export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [
{
@ -66,25 +81,31 @@ export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [
createCategory: {
category: addedCategoryMock,
__typename: "CreateCategoryPayload",
}
}
},
},
},
},
]
];
const deleteCategoryVariables: DeleteCategoryVariables = {
id: 'c2'
id: "c2",
};
const getDeletedCategoryMock = (): DeleteCategoryPayload | null => {
const categoryToBeDeleted = categoryNodesMock.find(q => q.id === deleteCategoryVariables.id)
return categoryToBeDeleted ? {
category: categoryToBeDeleted,
__typename: "DeleteCategoryPayload",
} : null
}
const categoryToBeDeleted = categoryNodesMock.find(
(q) => q.id === deleteCategoryVariables.id
);
return categoryToBeDeleted
? {
category: categoryToBeDeleted,
__typename: "DeleteCategoryPayload",
}
: null;
};
export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> = [
export const deleteCategoryMock: Array<
MockedResponse<DeleteCategoryResponse>
> = [
{
request: {
query: DELETE_CATEGORY,
@ -93,8 +114,7 @@ export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> =
result: {
data: {
deleteCategory: getDeletedCategoryMock(),
}
},
},
},
]
];

View File

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

View File

@ -1,5 +1,5 @@
import {MockedResponse} from "@apollo/client/testing";
import {LOGIN, LoginResponse} from "./login";
import { MockedResponse } from "@apollo/client/testing";
import { LOGIN, LoginResponse } from "./login";
export const loginMock: Array<MockedResponse<LoginResponse>> = [
{
@ -8,14 +8,14 @@ export const loginMock: Array<MockedResponse<LoginResponse>> = [
variables: {
email: "test@email.com",
password: "password",
}
},
},
result: {
data: {
authenticate: {
jwtToken: "123"
}
}
jwtToken: "123",
},
},
},
},
{
@ -24,14 +24,14 @@ export const loginMock: Array<MockedResponse<LoginResponse>> = [
variables: {
email: "test@email.com",
password: "wrong-password",
}
},
},
result: {
data: {
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`
mutation Login($email: String!, $password: String!) {
authenticate(input: {email: $email, password: $password}) {
jwtToken
}
}`
mutation Login($email: String!, $password: String!) {
authenticate(input: { email: $email, password: $password }) {
jwtToken
}
}
`;
export interface LoginVariables {
email: string,
password: string
email: string;
password: string;
}
export interface LoginResponse {
authenticate: {
jwtToken?: string
}
authenticate: {
jwtToken?: string;
};
}

View File

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

View File

@ -1,57 +1,86 @@
import {gql} from "@apollo/client";
import {BasicQuestionFragment, BasicQuestionResponse} from "../queries/question";
import { gql } from "@apollo/client";
import {
BasicQuestionFragment,
BasicQuestionResponse,
} from "../queries/question";
export const EDIT_QUESTION = gql`
mutation UpdateQuestion($id: ID!, $title: String, $description: String, $categoryRowId: Int) {
updateQuestion(input: {id: $id, questionPatch: {categoryRowId: $categoryRowId, description: $description, title: $title}}) {
mutation UpdateQuestion(
$id: ID!
$title: String
$description: String
$categoryRowId: Int
) {
updateQuestion(
input: {
id: $id
questionPatch: {
categoryRowId: $categoryRowId
description: $description
title: $title
}
}
) {
question {
...BasicQuestionFragment
}
}
}
${BasicQuestionFragment}
`
`;
export interface EditQuestionResponse {
updateQuestion: EditQuestionPayload | null,
updateQuestion: EditQuestionPayload | null;
}
export interface EditQuestionPayload {
question: BasicQuestionResponse,
__typename: "UpdateQuestionPayload",
question: BasicQuestionResponse;
__typename: "UpdateQuestionPayload";
}
export interface EditQuestionVariables {
id: string,
title?: string,
description?: string | null,
categoryRowId?: number | null,
id: string;
title?: string;
description?: string | null;
categoryRowId?: number | null;
}
export const ADD_QUESTION = gql`
mutation AddQuestion($title: String!, $description: String, $categoryRowId: Int) {
createQuestion(input: {question: {title: $title, categoryRowId: $categoryRowId, description: $description}}) {
mutation AddQuestion(
$title: String!
$description: String
$categoryRowId: Int
) {
createQuestion(
input: {
question: {
title: $title
categoryRowId: $categoryRowId
description: $description
}
}
) {
question {
...BasicQuestionFragment
}
}
}
${BasicQuestionFragment}
`
`;
export interface AddQuestionResponse {
createQuestion: AddQuestionPayload | null
createQuestion: AddQuestionPayload | null;
}
export interface AddQuestionPayload {
question: BasicQuestionResponse,
__typename: "CreateQuestionPayload",
question: BasicQuestionResponse;
__typename: "CreateQuestionPayload";
}
export interface AddQuestionVariables {
title: string,
description?: string | null,
categoryRowId?: number | null
title: string;
description?: string | null;
categoryRowId?: number | null;
}
export const DELETE_QUESTION = gql`
@ -63,19 +92,17 @@ export const DELETE_QUESTION = gql`
}
}
${BasicQuestionFragment}
`
`;
export interface DeleteQuestionResponse {
deleteQuestion: DeleteQuestionPayload | null,
deleteQuestion: DeleteQuestionPayload | null;
}
export interface DeleteQuestionPayload {
question: BasicQuestionResponse,
__typename: "DeleteQuestionPayload",
question: BasicQuestionResponse;
__typename: "DeleteQuestionPayload";
}
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`
mutation CreateAccount($firstName: String!, $lastName: String!, $email: String!, $password: String!) {
registerPerson(input: {firstName: $firstName, lastName: $lastName, email: $email, password: $password}) {
person {
id
}
}
mutation CreateAccount(
$firstName: String!
$lastName: String!
$email: String!
$password: String!
) {
registerPerson(
input: {
firstName: $firstName
lastName: $lastName
email: $email
password: $password
}
) {
person {
id
}
}
`
}
`;
export interface SignUpVariables {
firstName: string,
lastName: string,
email: string,
password: string,
firstName: string;
lastName: string;
email: string;
password: string;
}
export interface SignUpResponse {
registerPerson: {
person: {
id: string
}
}
registerPerson: {
person: {
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 {
BasicCategoryResponse,
GET_ALL_CATEGORIES,
GET_CATEGORY_BY_ID,
GetAllCategoriesResponse,
GetCategoryByIdResponse
GetCategoryByIdResponse,
} from "./category";
export const categoryNodesMock: Array<BasicCategoryResponse> = [
{
id: "c1",
rowId: 1,
title: "Category 1",
description: "Further information for C1",
__typename: "Category"
}, {
__typename: "Category",
},
{
id: "c2",
rowId: 2,
title: "Category 2",
description: "Further information for C2",
__typename: "Category"
}];
__typename: "Category",
},
];
export const getAllCategoriesMock: Array<MockedResponse<GetAllCategoriesResponse>> = [
export const getAllCategoriesMock: Array<
MockedResponse<GetAllCategoriesResponse>
> = [
{
request: {
query: GET_ALL_CATEGORIES,
@ -37,21 +40,24 @@ export const getAllCategoriesMock: Array<MockedResponse<GetAllCategoriesResponse
},
},
},
]
];
export const getCategoryByIdMock: Array<MockedResponse<GetCategoryByIdResponse>> = [...categoryNodesMock.map(c => ({
request: {
query: GET_CATEGORY_BY_ID,
variables: {
id: c.id,
export const getCategoryByIdMock: Array<
MockedResponse<GetCategoryByIdResponse>
> = [
...categoryNodesMock.map((c) => ({
request: {
query: GET_CATEGORY_BY_ID,
variables: {
id: c.id,
},
},
},
result: {
data: {
category: c,
result: {
data: {
category: c,
},
},
},
})),
})),
{
request: {
query: GET_CATEGORY_BY_ID,
@ -64,5 +70,5 @@ export const getCategoryByIdMock: Array<MockedResponse<GetCategoryByIdResponse>>
category: null,
},
},
}
]
},
];

View File

@ -1,4 +1,4 @@
import {gql} from "@apollo/client";
import { gql } from "@apollo/client";
export const BasicCategoryFragment = gql`
fragment BasicCategoryFragment on Category {
@ -7,14 +7,14 @@ export const BasicCategoryFragment = gql`
title
description
}
`
`;
export interface BasicCategoryResponse {
id: string,
rowId: number,
title: string,
description: string | null,
__typename: "Category",
id: string;
rowId: number;
title: string;
description: string | null;
__typename: "Category";
}
export const GET_ALL_CATEGORIES = gql`
@ -26,28 +26,28 @@ export const GET_ALL_CATEGORIES = gql`
}
}
${BasicCategoryFragment}
`
`;
export interface GetAllCategoriesResponse {
allCategories: {
nodes: Array<BasicCategoryResponse>,
__typename: "CategoriesConnection",
}
nodes: Array<BasicCategoryResponse>;
__typename: "CategoriesConnection";
};
}
export const GET_CATEGORY_BY_ID = gql`
query GetCategoryById($id:ID!) {
query GetCategoryById($id: ID!) {
category(id: $id) {
...BasicCategoryFragment
}
}
${BasicCategoryFragment}
`
`;
export interface GetCategoryByIdResponse {
category: BasicCategoryResponse | null,
category: BasicCategoryResponse | null;
}
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 {
BasicQuestionResponse,
GET_ALL_QUESTIONS,
GET_QUESTION_BY_ID,
GetAllQuestionsResponse,
GetQuestionByIdResponse
GetQuestionByIdResponse,
} from "./question";
export const questionNodesMock: Array<BasicQuestionResponse> = [{
id: "q1",
title: "Question 1?",
description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
export const questionNodesMock: Array<BasicQuestionResponse> = [
{
id: "q1",
rowId: 1,
title: "Category 1",
__typename: "Category"
title: "Question 1?",
description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
rowId: 1,
title: "Category 1",
__typename: "Category",
},
__typename: "Question",
},
__typename: "Question",
},
{
id: "q2",
rowId: 2,
title: "Question 2?",
description: "Further information for Q2",
categoryByCategoryRowId: null,
@ -29,14 +31,17 @@ export const questionNodesMock: Array<BasicQuestionResponse> = [{
},
{
id: "q3",
rowId: 3,
title: "Question 3?",
description: null,
categoryByCategoryRowId: null,
__typename: "Question",
}
},
];
export const getAllQuestionsMock: Array<MockedResponse<GetAllQuestionsResponse>> = [
export const getAllQuestionsMock: Array<
MockedResponse<GetAllQuestionsResponse>
> = [
{
request: {
query: GET_ALL_QUESTIONS,
@ -46,14 +51,16 @@ export const getAllQuestionsMock: Array<MockedResponse<GetAllQuestionsResponse>>
allQuestions: {
nodes: questionNodesMock,
__typename: "QuestionsConnection",
}
}
},
},
},
},
]
];
export const getQuestionByIdMock: Array<MockedResponse<GetQuestionByIdResponse>> = [
...questionNodesMock.map(q => ({
export const getQuestionByIdMock: Array<
MockedResponse<GetQuestionByIdResponse>
> = [
...questionNodesMock.map((q) => ({
request: {
query: GET_QUESTION_BY_ID,
variables: {
@ -78,6 +85,5 @@ export const getQuestionByIdMock: Array<MockedResponse<GetQuestionByIdResponse>>
question: null,
},
},
}
]
},
];

View File

@ -1,4 +1,4 @@
import {gql} from "@apollo/client";
import { gql } from "@apollo/client";
const QuestionCategoryFragment = gql`
fragment QuestionCategoryFragment on Category {
@ -6,18 +6,19 @@ const QuestionCategoryFragment = gql`
rowId
title
}
`
`;
interface GetQuestionsCategoryResponse {
id: string,
rowId: number,
title: string,
__typename: "Category",
id: string;
rowId: number;
title: string;
__typename: "Category";
}
export const BasicQuestionFragment = gql`
fragment BasicQuestionFragment on Question {
id
rowId
title
description
categoryByCategoryRowId {
@ -25,14 +26,15 @@ export const BasicQuestionFragment = gql`
}
}
${QuestionCategoryFragment}
`
`;
export interface BasicQuestionResponse {
id: string,
title: string,
description: string | null,
categoryByCategoryRowId: GetQuestionsCategoryResponse | null,
__typename: "Question",
id: string;
rowId: number;
title: string;
description: string | null;
categoryByCategoryRowId: GetQuestionsCategoryResponse | null;
__typename: "Question";
}
export const GET_ALL_QUESTIONS = gql`
@ -44,28 +46,28 @@ export const GET_ALL_QUESTIONS = gql`
}
}
${BasicQuestionFragment}
`
`;
export interface GetAllQuestionsResponse {
allQuestions: {
nodes: Array<BasicQuestionResponse>,
__typename: "QuestionsConnection",
}
nodes: Array<BasicQuestionResponse>;
__typename: "QuestionsConnection";
};
}
export const GET_QUESTION_BY_ID = gql`
query GetQuestionById($id:ID!) {
query GetQuestionById($id: ID!) {
question(id: $id) {
...BasicQuestionFragment
}
}
${BasicQuestionFragment}
`
`;
export interface GetQuestionByIdResponse {
question: BasicQuestionResponse | null,
question: BasicQuestionResponse | null;
}
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 {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 AccordionActions from '@material-ui/core/AccordionActions';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import Typography from '@material-ui/core/Typography';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import Divider from '@material-ui/core/Divider';
import {IconButton} from '@material-ui/core';
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 AccordionActions from "@material-ui/core/AccordionActions";
import DeleteIcon from "@material-ui/icons/Delete";
import EditIcon from "@material-ui/icons/Edit";
import Typography from "@material-ui/core/Typography";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import Divider from "@material-ui/core/Divider";
import { IconButton } from "@material-ui/core";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
width: '100%',
marginBottom: theme.spacing(1)
width: "100%",
marginBottom: theme.spacing(1),
},
heading: {
fontSize: theme.typography.pxToRem(15),
@ -26,28 +26,30 @@ const useStyles = makeStyles((theme: Theme) =>
color: theme.palette.text.secondary,
},
details: {
alignItems: 'center',
alignItems: "center",
},
}),
})
);
interface AccordionWithEditProps {
key: string,
title: string,
description: string | null,
subTitle?: string | null,
onEditButtonClick?(): void,
onDeleteButtonClick?(): void,
key: string;
title: string;
description: string | null;
subTitle?: string | null;
onEditButtonClick?(): void;
onDeleteButtonClick?(): void;
}
export default function AccordionWithEdit(props: AccordionWithEditProps) {
export default function AccordionWithEdit(
props: AccordionWithEditProps
): React.ReactElement {
const classes = useStyles();
return (
<div className={classes.root}>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon/>}
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1c-content"
id="panel1c-header"
>
@ -59,20 +61,30 @@ export default function AccordionWithEdit(props: AccordionWithEditProps) {
</div>
</AccordionSummary>
<AccordionDetails className={classes.details}>
<Typography color="textSecondary" style={{whiteSpace: "pre-line"}}>
{props.description}
<Typography color="textSecondary" style={{ whiteSpace: "pre-line" }}>
{props.description}
</Typography>
</AccordionDetails>
<Divider/>
<Divider />
<AccordionActions>
<IconButton data-testid="edit-icon-button" size={"small"} aria-label="edit" onClick={props.onEditButtonClick}>
<EditIcon titleAccess="Anpassen"/>
<IconButton
data-testid="edit-icon-button"
size={"small"}
aria-label="edit"
onClick={props.onEditButtonClick}
>
<EditIcon titleAccess="Anpassen" />
</IconButton>
<IconButton data-testid="delete-icon-button" size={"small"} aria-label="delete" onClick={props.onDeleteButtonClick}>
<DeleteIcon titleAccess="Löschen"/>
<IconButton
data-testid="delete-icon-button"
size={"small"}
aria-label="delete"
onClick={props.onDeleteButtonClick}
>
<DeleteIcon titleAccess="Löschen" />
</IconButton>
</AccordionActions>
</Accordion>
</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 {makeStyles} from "@material-ui/core/styles";
import AddIcon from '@material-ui/icons/Add';
import { makeStyles } from "@material-ui/core/styles";
import AddIcon from "@material-ui/icons/Add";
const useStyles = makeStyles((theme) => ({
root: {
width: '100%',
width: "100%",
marginBottom: theme.spacing(1),
},
addCardContent: {
padding: 0,
},
addCardIcon: {
display: 'block',
margin: 'auto',
display: "block",
margin: "auto",
padding: 12,
},
}));
interface AddCardProps {
handleClick?(): void
handleClick?(): void;
}
export default function AddCard(props: AddCardProps) {
export default function AddCard(props: AddCardProps): React.ReactElement {
const classes = useStyles();
return (
<Card className={classes.root}>
<CardActionArea onClick={props.handleClick}>
<CardContent color={"textSecondary"} className={classes.addCardContent}>
<AddIcon className={classes.addCardIcon}/>
<AddIcon className={classes.addCardIcon} />
</CardContent>
</CardActionArea>
</Card>
)
);
}

View File

@ -1,57 +1,63 @@
import React from 'react';
import {createStyles, makeStyles, Theme} from '@material-ui/core/styles';
import CircularProgress from '@material-ui/core/CircularProgress';
import {green} from '@material-ui/core/colors';
import Button from '@material-ui/core/Button';
import React from "react";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import CircularProgress from "@material-ui/core/CircularProgress";
import { green } from "@material-ui/core/colors";
import Button from "@material-ui/core/Button";
import { PropTypes } from "@material-ui/core";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
wrapper: {
margin: theme.spacing(1),
position: 'relative',
position: "relative",
},
buttonProgress: {
color: green[500],
position: 'absolute',
top: '50%',
left: '50%',
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
button: {
margin: theme.spacing(3, 0, 2),
},
}),
})
);
interface ButtonWithSpinnerProps {
children: string,
onClick?: () => void,
loading?: boolean
type?: "button" | "submit",
fullWidth?: boolean,
autoFocus?: boolean
children: string;
onClick?: () => void;
loading?: boolean;
type?: "button" | "submit";
fullWidth?: 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();
return (
<div className={classes.wrapper}>
<Button
className={classes.button}
variant="contained"
color="primary"
fullWidth={!!props.fullWidth}
type={props.type}
disabled={props.loading}
onClick={props.onClick}
autoFocus={props.autoFocus}
>
{props.children}
</Button>
{props.loading && <CircularProgress size={24} className={classes.buttonProgress} />}
</div>
<div className={classes.wrapper}>
<Button
className={`${classes.button} ${props.className}`}
variant="contained"
color={props.color || "primary"}
fullWidth={!!props.fullWidth}
type={props.type}
disabled={props.loading}
onClick={props.onClick}
autoFocus={props.autoFocus}
>
{props.children}
</Button>
{props.loading && (
<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 {makeStyles} from "@material-ui/core/styles";
import {useQuery} from "@apollo/client";
import { makeStyles } from "@material-ui/core/styles";
import { useQuery } from "@apollo/client";
import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit";
import {BasicCategoryResponse, GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category";
import DialogChangeCategory, {dialogChangeCategoryId, dialogChangeCategoryOpen} from "./DialogChangeCategory";
import {
BasicCategoryResponse,
GET_ALL_CATEGORIES,
GetAllCategoriesResponse,
} from "../backend/queries/category";
import DialogChangeCategory, {
dialogChangeCategoryId,
dialogChangeCategoryOpen,
} from "./DialogChangeCategory";
import DialogDeleteCategory, {
dialogDeleteCategoryId,
dialogDeleteCategoryOpen,
dialogDeleteCategoryTitle
dialogDeleteCategoryTitle,
} from "./DialogDeleteCategory";
const useStyles = makeStyles((theme) => ({
root: {
width: '100%',
width: "100%",
padding: theme.spacing(1),
marginBottom: theme.spacing(3),
},
}));
export default function CategoryList() {
const categories = useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data?.allCategories.nodes;
export default function CategoryList(): React.ReactElement {
const categories = useQuery<GetAllCategoriesResponse, null>(
GET_ALL_CATEGORIES
).data?.allCategories.nodes;
const classes = useStyles();
const handleAddClick = () => {
dialogChangeCategoryId("")
dialogChangeCategoryOpen(true)
}
dialogChangeCategoryId("");
dialogChangeCategoryOpen(true);
};
const handleEditButtonClick = (category: BasicCategoryResponse) => {
dialogChangeCategoryId(category.id);
dialogChangeCategoryOpen(true)
dialogChangeCategoryOpen(true);
};
const handleDeleteButtonClick = (category: BasicCategoryResponse) => {
dialogDeleteCategoryTitle(category.title);
dialogDeleteCategoryId(category.id);
dialogDeleteCategoryOpen(true);
}
};
return (
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Kategorien</Typography>
{categories?.map(category => <AccordionWithEdit
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
Kategorien
</Typography>
{categories?.map((category) => (
<AccordionWithEdit
key={category.id}
title={category.title}
description={category.description}
onEditButtonClick={() => handleEditButtonClick(category)}
onDeleteButtonClick={() => handleDeleteButtonClick(category)}
/>
)}
<AddCard handleClick={handleAddClick}/>
<DialogChangeCategory/>
<DialogDeleteCategory/>
))}
<AddCard handleClick={handleAddClick} />
<DialogChangeCategory />
<DialogDeleteCategory />
</Paper>
)
);
}

View File

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

View File

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

View File

@ -1,12 +1,11 @@
import React from 'react';
import AppBar from '@material-ui/core/AppBar';
import {IconButton, MenuItem, Toolbar, Typography} from '@material-ui/core';
import MenuIcon from '@material-ui/icons/Menu';
import Menu from '@material-ui/core/Menu';
import AccountCircle from '@material-ui/icons/AccountCircle';
import {makeStyles} from "@material-ui/core/styles";
import {useHistory} from 'react-router-dom';
import React from "react";
import AppBar from "@material-ui/core/AppBar";
import { IconButton, MenuItem, Toolbar, Typography } from "@material-ui/core";
import MenuIcon from "@material-ui/icons/Menu";
import Menu from "@material-ui/core/Menu";
import AccountCircle from "@material-ui/icons/AccountCircle";
import { makeStyles } from "@material-ui/core/styles";
import { useHistory } from "react-router-dom";
const useStyles = makeStyles({
menuButton: {
@ -15,10 +14,10 @@ const useStyles = makeStyles({
title: {
flexGrow: 1,
},
})
});
function CustomAppBar() {
const classes = useStyles()
function CustomAppBar(): React.ReactElement {
const classes = useStyles();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const history = useHistory();
@ -28,52 +27,57 @@ function CustomAppBar() {
};
const handleLogout = () => {
localStorage.removeItem('token')
history.push("/login")
}
localStorage.removeItem("token");
history.push("/login");
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<AppBar>
<Toolbar>
<IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="menu">
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
Candymat
</Typography>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</Toolbar>
</AppBar>
);
return (
<AppBar>
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
color="inherit"
aria-label="menu"
>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
Candymat
</Typography>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</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 React from "react";
interface DialogSimpleActionProps {
confirmButtonText?: string,
loading?: boolean,
confirmButtonText?: string;
loading?: boolean;
onClose(): void,
onClose(): void;
onConfirmButtonClick(): void,
onConfirmButtonClick(): void;
}
export function DialogActionBar(props: DialogSimpleActionProps) {
return <DialogActions>
<Button onClick={props.onClose} color="primary">
Abbrechen
</Button>
<ButtonWithSpinner onClick={props.onConfirmButtonClick} autoFocus loading={props.loading}>
{props.confirmButtonText || "Ok"}
</ButtonWithSpinner>
</DialogActions>;
export function DialogActionBar(
props: DialogSimpleActionProps
): React.ReactElement {
return (
<DialogActions>
<Button onClick={props.onClose} color="primary">
Abbrechen
</Button>
<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 Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {DialogActionBar} from "./DialogActionBar";
import {DialogTitleAndDetails} from "./DialogTitleAndDetails";
import {makeVar, useMutation, useQuery, useReactiveVar} from "@apollo/client";
import {useSnackbar} from "notistack";
import React, { useState } from "react";
import Dialog from "@material-ui/core/Dialog";
import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from "@material-ui/core/DialogTitle";
import { DialogActionBar } from "./DialogActionBar";
import { DialogTitleAndDetails } from "./DialogTitleAndDetails";
import { makeVar, useMutation, useQuery, useReactiveVar } from "@apollo/client";
import { useSnackbar } from "notistack";
import {
BasicCategoryFragment,
BasicCategoryResponse,
GET_CATEGORY_BY_ID,
GetCategoryByIdResponse,
GetCategoryByIdVariables
GetCategoryByIdVariables,
} from "../backend/queries/category";
import {
ADD_CATEGORY,
@ -19,65 +19,89 @@ import {
AddCategoryVariables,
EDIT_CATEGORY,
EditCategoryResponse,
EditCategoryVariables
EditCategoryVariables,
} from "../backend/mutations/category";
export const dialogChangeCategoryId = makeVar<string>("");
export const dialogChangeCategoryOpen = makeVar<boolean>(false);
export default function DialogChangeCategory() {
export default function DialogChangeCategory(): React.ReactElement {
const [addMode, setAddMode] = useState(true);
const [title, setTitle] = useState("");
const [details, setDetails] = useState("");
const categoryId = useReactiveVar(dialogChangeCategoryId);
const open = useReactiveVar(dialogChangeCategoryOpen);
const {enqueueSnackbar} = useSnackbar();
useQuery<GetCategoryByIdResponse, GetCategoryByIdVariables>(GET_CATEGORY_BY_ID, {
variables: {
id: categoryId,
},
onCompleted: (data => {
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 { enqueueSnackbar } = useSnackbar();
useQuery<GetCategoryByIdResponse, GetCategoryByIdVariables>(
GET_CATEGORY_BY_ID,
{
variables: {
id: categoryId,
},
onCompleted: (data) => {
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",
}),
onCompleted: (response) => {
if (response.updateCategory) {
enqueueSnackbar("Kategorie erfolgreich geändert.", {variant: "success"})
enqueueSnackbar("Kategorie erfolgreich geändert.", {
variant: "success",
});
dialogChangeCategoryOpen(false);
} else {
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"})
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({
fields: {
allCategories(existingCategories = {nodes: []}) {
const newCategoryRef = cache.writeFragment<BasicCategoryResponse | undefined>({
allCategories(existingCategories = { nodes: [] }) {
const newCategoryRef = cache.writeFragment<
BasicCategoryResponse | undefined
>({
data: data?.createCategory?.category,
fragment: BasicCategoryFragment,
fragmentName: "BasicCategoryFragment",
});
return {nodes: [...existingCategories.nodes, newCategoryRef]};
}
}
return { nodes: [...existingCategories.nodes, newCategoryRef] };
},
},
});
}
},
});
const handleConfirmButtonClick = () => {
@ -86,21 +110,25 @@ export default function DialogChangeCategory() {
variables: {
title,
description: details,
}
})
},
});
} else {
editCategory({
variables: {
id: categoryId,
title: title,
description: details,
}
})
},
});
}
}
};
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">
{addMode ? "Neue Kategorie erstellen" : "Kategorie bearbeiten"}
</DialogTitle>
@ -108,8 +136,8 @@ export default function DialogChangeCategory() {
<DialogTitleAndDetails
title={title}
details={details}
onTitleChange={newTitle => setTitle(newTitle)}
onDetailsChange={newDetails => setDetails(newDetails)}
onTitleChange={(newTitle) => setTitle(newTitle)}
onDetailsChange={(newDetails) => setDetails(newDetails)}
/>
</DialogContent>
<DialogActionBar

View File

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

View File

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

View File

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

View File

@ -1,24 +1,25 @@
import React from 'react';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {DialogContentText} from "@material-ui/core";
import {DialogActionBar} from "./DialogActionBar";
import React from "react";
import Dialog from "@material-ui/core/Dialog";
import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from "@material-ui/core/DialogTitle";
import { DialogContentText } from "@material-ui/core";
import { DialogActionBar } from "./DialogActionBar";
interface DialogSimpleProps {
open: boolean,
title: string,
confirmButtonText: string,
description: string,
loading?: boolean,
open: boolean;
title: string;
confirmButtonText: string;
description: string;
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 (
<Dialog
open={props.open}
@ -40,4 +41,3 @@ export default function DialogSimple(props: DialogSimpleProps) {
</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 TextField from "@material-ui/core/TextField";
const useStyles = makeStyles((theme) => ({
textField: {
marginBottom: theme.spacing(2),
}
},
}));
interface DialogTitleAndDetailsProps {
title: string,
details?: string | null,
title: string;
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();
return (
@ -29,7 +31,7 @@ export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) {
fullWidth
variant="outlined"
value={props.title}
onChange={e => props.onTitleChange(e.target.value)}
onChange={(e) => props.onTitleChange(e.target.value)}
/>
<TextField
className={classes.textField}
@ -40,8 +42,8 @@ export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) {
fullWidth
variant="outlined"
value={props.details}
onChange={e => props.onDetailsChange(e.target.value)}
onChange={(e) => props.onDetailsChange(e.target.value)}
/>
</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 {render, screen} from '@testing-library/react'
import {MockedProvider} from '@apollo/client/testing';
import {MemoryRouter} from 'react-router-dom';
import React from "react";
import { render, screen } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import Main from "./Main";
import {SnackbarProvider} from "notistack";
import { SnackbarProvider } from "notistack";
function renderMainPage() {
render(<MockedProvider><MemoryRouter><SnackbarProvider><Main/></SnackbarProvider></MemoryRouter></MockedProvider>);
render(
<MockedProvider>
<MemoryRouter>
<SnackbarProvider>
<Main />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
describe('The main page', () => {
test('displays the editors page if an editor is logged in', () => {
const editorToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c";
localStorage.setItem("token", editorToken)
describe("The main page", () => {
test("displays the editors page if an editor is logged in", () => {
const editorToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c";
localStorage.setItem("token", editorToken);
renderMainPage();
// it renders question and category lists
@ -23,30 +31,35 @@ describe('The main page', () => {
expect(categoryListHeadline).not.toBeNull();
});
test('displays the candidates page if a candidate is logged in', () => {
const candidateToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg";
localStorage.setItem("token", candidateToken)
test("displays the candidates page if a candidate is logged in", () => {
const candidateToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg";
localStorage.setItem("token", candidateToken);
renderMainPage();
const placeholder = screen.queryByText(/Under construction/);
expect(placeholder).not.toBeNull();
const questionListHeadline = screen.queryByText(/Fragen/);
const categoryListHeadline = screen.queryByText(/Kategorien/);
expect(questionListHeadline).not.toBeNull();
expect(categoryListHeadline).toBeNull();
});
test('displays the user page if an normal user is logged in', () => {
const userToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", userToken)
test("displays the user page if an normal user is logged in", () => {
const userToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", userToken);
renderMainPage();
const placeholder = screen.queryByText(/nichts zu sehen/);
expect(placeholder).not.toBeNull();
});
test('displays a link to the loggin page if something is wrong with the token', () => {
const invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", invalidToken)
test("displays a link to the loggin page if something is wrong with the token", () => {
const invalidToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", invalidToken);
renderMainPage();
const placeholder = screen.queryByRole("link", {name: /Login Seite/});
const placeholder = screen.queryByRole("link", { name: /Login Seite/ });
expect(placeholder).not.toBeNull();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,62 +1,68 @@
import {ApolloError} from "@apollo/client";
import { ApolloError } from "@apollo/client";
export interface SignUpError {
message: string,
emailInvalid: boolean,
firstNameInvalid: boolean,
lastNameInvalid: boolean,
passwordInvalid: boolean
message: string;
emailInvalid: boolean;
firstNameInvalid: boolean;
lastNameInvalid: boolean;
passwordInvalid: boolean;
}
const parseErrorMessage = (error: ApolloError): string => {
let result = "Sign-up failed because of the following reason(s): ";
if (isEmailAlreadyUsed(error)) {
result += "The E-Mail is already in use. "
result += "The E-Mail is already in use. ";
}
if (isFirstNameInvalid(error)) {
result += "The provided 'First Name' is invalid. "
result += "The provided 'First Name' is invalid. ";
}
if (isLastNameInvalid(error)) {
result += "The provided 'Last Name' is invalid. "
result += "The provided 'Last Name' is invalid. ";
}
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 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 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 errorMessage = error.message.toLowerCase();
return errorMessage.includes("invalid") && errorMessage.includes("last name");
}
};
const isPasswordInvalid = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase();
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 {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}

View File

@ -1,22 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import {ApolloProvider} from "@apollo/client";
import {client} from "./backend/helper";
import {BrowserRouter as Router} from "react-router-dom";
import {SnackbarProvider} from "notistack";
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import { ApolloProvider } from "@apollo/client";
import { client } from "./backend/helper";
import { BrowserRouter as Router } from "react-router-dom";
import { SnackbarProvider } from "notistack";
ReactDOM.render(
<ApolloProvider client={client}>
<Router>
<SnackbarProvider maxSnack={3}>
<App/>
<App />
</SnackbarProvider>
</Router>
</ApolloProvider>,
document.getElementById('root')
document.getElementById("root")
);
// 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 {render, screen} from '@testing-library/react'
import {MockedProvider} from '@apollo/client/testing';
import {MemoryRouter} from 'react-router-dom';
import React from "react";
import { render, screen } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import App from "../App";
import {SnackbarProvider} from "notistack";
import { SnackbarProvider } from "notistack";
const renderAppAtUrl = (path: string) => render(
<MockedProvider>
<MemoryRouter initialEntries={[path]}>
<SnackbarProvider>
<App/>
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
const renderAppAtUrl = (path: string) =>
render(
<MockedProvider>
<MemoryRouter initialEntries={[path]}>
<SnackbarProvider>
<App />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
beforeEach(() => localStorage.clear())
beforeEach(() => localStorage.clear());
describe('The root path /', () => {
test('renders user\'s home page if they are logged in', () => {
localStorage.setItem("token", "asdfasdfasdf")
describe("The root path /", () => {
test("renders user's home page if they are logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
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("/");
const emailField = screen.getByRole('textbox', {name: 'Email Address'});
const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/);
expect(emailField).toHaveValue("");
expect(passwordField).toHaveValue("");
});
});
describe('The /login path', () => {
test('renders the signin page if the user is not logged in', () => {
describe("The /login path", () => {
test("renders the signin page if the user is not logged in", () => {
renderAppAtUrl("/login");
const emailField = screen.getByRole('textbox', {name: 'Email Address'});
const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/);
expect(emailField).toHaveValue("");
expect(passwordField).toHaveValue("");
});
test('redirects to root / and the user\'s home page if the user is logged in', () => {
localStorage.setItem("token", "asdfasdfasdf")
test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
renderAppAtUrl("/login");
expect(() => screen.getByLabelText(/current user/)).not.toThrow()
expect(() => screen.getByLabelText(/current user/)).not.toThrow();
});
});
describe('The /signup path', () => {
test('renders the signup page if the user is not logged in', () => {
describe("The /signup path", () => {
test("renders the signup page if the user is not logged in", () => {
renderAppAtUrl("/signup");
expect(() => screen.getByRole('textbox', {name: 'Email Address'})).not.toThrow()
expect(() => screen.getByLabelText(/Password/)).not.toThrow()
expect(() => screen.getByLabelText(/First Name/)).not.toThrow()
expect(() => screen.getByLabelText(/Last Name/)).not.toThrow()
expect(() =>
screen.getByRole("textbox", { name: "Email Address" })
).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', () => {
localStorage.setItem("token", "asdfasdfasdf")
test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
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 {fireEvent, render, screen, waitFor} from '@testing-library/react'
import {MockedProvider, MockedResponse} from '@apollo/client/testing';
import {MemoryRouter} from 'react-router-dom';
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import CategoryList from "../components/CategoryList";
import {SnackbarProvider} from "notistack";
import {categoryNodesMock, getAllCategoriesMock, getCategoryByIdMock} from "../backend/queries/category.mock";
import {addCategoryMock, deleteCategoryMock, editCategoryMock} from "../backend/mutations/category.mock";
import {expandAccordionAndGetIconButtons, queryAllAddIconButtons, queryAllEditIconButtons} from "./test-helper";
import { SnackbarProvider } from "notistack";
import {
categoryNodesMock,
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', () => {
test('displays the existing categories, but not the details of it', async () => {
describe("The CategoryList", () => {
test("displays the existing categories, but not the details of it", async () => {
renderCategoryList();
const categoryCards = await waitForInitialCategoriesToRender()
categoryCards.forEach(card => {
expect(card.innerHTML).toMatch(/Category [1-2]/)
})
expect(queryAllEditIconButtons()).toHaveLength(0)
const categoryCards = await waitForInitialCategoriesToRender();
categoryCards.forEach((card) => {
expect(card.innerHTML).toMatch(/Category [1-2]/);
});
expect(queryAllEditIconButtons()).toHaveLength(0);
});
test('enables toggling details on each category', async () => {
test("enables toggling details on each category", async () => {
renderCategoryList();
// Initial state: Every category card is not expanded
const categoryCards = await waitForInitialCategoriesToRender()
const categoryCards = await waitForInitialCategoriesToRender();
// Expand first category card
await expandAccordionAndGetIconButtons(categoryCards[0])
await expandAccordionAndGetIconButtons(categoryCards[0]);
// Shrink first category card again
fireEvent.click(categoryCards[0])
fireEvent.click(categoryCards[0]);
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);
const categoryCards = await waitForInitialCategoriesToRender();
const {editIconButton} = await expandAccordionAndGetIconButtons(categoryCards[0]);
const { editIconButton } = await expandAccordionAndGetIconButtons(
categoryCards[0]
);
// open edit dialog
expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull();
fireEvent.click(editIconButton);
await waitFor(() => {
expect(screen.queryByText(/Kategorie bearbeiten/)).not.toBeNull();
})
});
// change category title
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(() => {
expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull();
})
});
// call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Speichern/});
const confirmButton = screen.getByRole("button", { name: /Speichern/ });
fireEvent.click(confirmButton);
await waitFor(() => {
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);
await waitForInitialCategoriesToRender();
@ -76,69 +91,74 @@ describe('The CategoryList', () => {
fireEvent.click(addButton);
await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).not.toBeNull();
})
});
// change category title
const categoryTitleField = screen.getByLabelText(/Zusammenfassung/);
fireEvent.change(categoryTitleField, {target: {value: "New category"}});
fireEvent.change(categoryTitleField, { target: { value: "New category" } });
await waitFor(() => {
expect(screen.queryByDisplayValue(/New category/)).not.toBeNull();
})
});
// call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Erstellen/});
const confirmButton = screen.getByRole("button", { name: /Erstellen/ });
fireEvent.click(confirmButton);
await waitFor(() => {
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);
const categoryCards = await waitForInitialCategoriesToRender();
expect(screen.queryByText(/Category 2/)).not.toBeNull();
const {deleteIconButton} = await expandAccordionAndGetIconButtons(categoryCards[1]);
const { deleteIconButton } = await expandAccordionAndGetIconButtons(
categoryCards[1]
);
// open delete confirmation dialog
expect(screen.queryByText(/Kategorie löschen/)).toBeNull();
fireEvent.click(deleteIconButton);
await waitFor(() => {
expect(screen.queryByText(/Kategorie löschen/)).not.toBeNull();
})
});
// 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);
await waitFor(() => {
expect(screen.queryByText(/Kategorie löschen/)).toBeNull();
expect(screen.queryByText(/Category 2/)).toBeNull();
})
});
});
});
function renderCategoryList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<CategoryList/>
<CategoryList />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialCategoriesToRender = async (): Promise<Array<HTMLElement>> => {
const waitForInitialCategoriesToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = [];
await waitFor(() => {
categoryCards = screen.queryAllByRole("button", {name: /Category [1-2]/})
categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
});
return categoryCards;
}
};

View File

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

View File

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

View File

@ -1,50 +1,110 @@
import React from 'react';
import {fireEvent, queryAllByRole, 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 React from "react";
import {
fireEvent,
queryAllByRole,
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) => {
let cache: { path?: string } = {};
const cache: { path?: string } = {};
return (): string => {
if (cache?.path) {
return cache.path
return cache.path;
} else {
const {container} = render(icon)
const path = container.innerHTML.match(/<path d="(.*)">/)?.[1]
const { container } = render(icon);
const path = container.innerHTML.match(/<path d="(.*)">/)?.[1];
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
return path
cache.path = path;
return path;
}
}
}
};
};
const getEditIconPath = memoizedGetIconPath(<EditIcon/>)
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon/>)
const getAddIconPath = memoizedGetIconPath(<AddIcon/>)
const getEditIconPath = memoizedGetIconPath(<EditIcon />);
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon />);
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...
export const queryAllEditIconButtons = (container?: HTMLElement): Array<HTMLElement> => {
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button"))
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getEditIconPath()));
}
export const queryAllEditIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getEditIconPath())
);
};
// sorry, I found no better way to find a specific icon button...
const queryAllDeleteIconButtons = (container?: HTMLElement): Array<HTMLElement> => {
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button"))
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getDeleteIconPath()));
}
const queryAllDeleteIconButtons = (
container?: HTMLElement
): 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...
export const queryAllAddIconButtons = (container?: HTMLElement): Array<HTMLElement> => {
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button"))
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getAddIconPath()));
}
export const queryAllAddIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getAddIconPath())
);
};
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 deleteIconsButtons = queryAllEditIconButtons();
expect(editIconsButtons).toHaveLength(0);
@ -55,9 +115,9 @@ export const expandAccordionAndGetIconButtons = async (accordion: HTMLElement):
deleteIconsButtons = queryAllDeleteIconButtons();
expect(editIconsButtons).toHaveLength(1);
expect(deleteIconsButtons).toHaveLength(1);
})
});
return {
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", () => {
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?.person_row_id).toBe(1);
@ -12,25 +13,28 @@ describe("The parseJwt function", () => {
});
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);
expect(jwt).toBeNull();
})
});
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);
expect(jwt).toBeNull();
})
});
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);
expect(jwt).toBeNull();
})
});
});

View File

@ -1,48 +1,49 @@
export const getRawJsonWebToken = (): string | null => {
return localStorage.getItem('token');
}
return localStorage.getItem("token");
};
export const getJsonWebToken = (): JwtPayload | null => {
const rawToken = getRawJsonWebToken();
return rawToken ? parseJwt(rawToken) : null
}
return rawToken ? parseJwt(rawToken) : null;
};
export const parseJwt = (token: string): JwtPayload | null => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
const jwtPayload = JSON.parse(jsonPayload);
return isJwtPayloadValid(jwtPayload) ? jwtPayload : null
return isJwtPayloadValid(jwtPayload) ? jwtPayload : null;
} catch {
return null
return null;
}
}
};
export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => {
return claims.every(claim => Object.keys(jwtPayload).includes(claim))
&& userRoles.includes(jwtPayload.role)
&& typeof (jwtPayload.person_row_id) === 'number'
&& typeof (jwtPayload.exp) === 'number'
&& typeof (jwtPayload.iat) === 'number';
}
return (
claims.every((claim) => Object.keys(jwtPayload).includes(claim)) &&
userRoles.includes(jwtPayload.role) &&
typeof jwtPayload.person_row_id === "number" &&
typeof jwtPayload.exp === "number" &&
typeof jwtPayload.iat === "number"
);
};
const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"]
const userRoles = ["candymat_editor", 'candymat_candidate', 'candymat_person']
const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"];
const userRoles = ["candymat_editor", "candymat_candidate", "candymat_person"];
interface JwtPayload {
"role": UserRole,
"person_row_id": number,
"exp": number,
"iat": number,
"aud": "postgraphile",
"iss": "postgraphile"
role: UserRole;
person_row_id: number;
exp: number;
iat: number;
aud: "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
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
window.location.hostname === "[::1]" ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^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;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
export function register(config?: Config): void {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(
process.env.PUBLIC_URL,
window.location.href
);
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
@ -39,7 +36,7 @@ export function register(config?: Config) {
return;
}
window.addEventListener('load', () => {
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
@ -50,8 +47,8 @@ export function register(config?: Config) {
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://bit.ly/CRA-PWA"
);
});
} else {
@ -65,21 +62,21 @@ export function register(config?: Config) {
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
"New content is available and will be used when all " +
"tabs for this page are closed. See https://bit.ly/CRA-PWA."
);
// Execute callback
@ -90,7 +87,7 @@ function registerValidSW(swUrl: string, config?: Config) {
// At this point, everything has been precached.
// It's the perfect time to display a
// "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
if (config && config.onSuccess) {
@ -101,25 +98,25 @@ function registerValidSW(swUrl: string, config?: Config) {
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
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.
const contentType = response.headers.get('content-type');
const contentType = response.headers.get("content-type");
if (
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.
navigator.serviceWorker.ready.then(registration => {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
@ -131,18 +128,18 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
})
.catch(() => {
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() {
if ('serviceWorker' in navigator) {
export function unregister(): void {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
.then(registration => {
.then((registration) => {
registration.unregister();
})
.catch(error => {
.catch((error) => {
console.error(error.message);
});
}

View File

@ -2,4 +2,4 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// 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": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@ -19,7 +15,5 @@
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
"include": ["src"]
}