#14 Implement possibility to answer questions for canditates

This commit is contained in:
Christoph Lienhard 2021-01-05 18:28:24 +01:00
parent 6ea057a1cf
commit 5e219089f6
Signed by: christoph.lienhard
GPG key ID: 6B98870DDC270884
15 changed files with 690 additions and 29 deletions

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)

View file

@ -0,0 +1,121 @@
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 => {
console.log(answerRefs)
return {nodes: [...answerRefs.nodes, newAnswerRef]}
},
}
});
}
const addAnswerToRootField = (
cache: ApolloCache<AddAnswerResponse>, newAnswerRef: Reference, personRowId: number, questionRowId: number
) => {
cache.modify({
fields: {
answerByQuestionRowIdAndPersonRowId: (
answerRefs: Reference | StoreObject | null = null,
{storeFieldName}
): Reference | StoreObject | void => {
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
) => {
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

@ -55,6 +55,7 @@ const addQuestionVariables: AddQuestionVariables = {
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,
@ -103,4 +104,3 @@ export const deleteQuestionMock: Array<MockedResponse<DeleteQuestionResponse>> =
},
},
]

View file

@ -0,0 +1,95 @@
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

@ -10,6 +10,7 @@ import {
export const questionNodesMock: Array<BasicQuestionResponse> = [{
id: "q1",
rowId: 1,
title: "Question 1?",
description: "Further information for Q1",
categoryByCategoryRowId: {
@ -22,6 +23,7 @@ export const questionNodesMock: Array<BasicQuestionResponse> = [{
},
{
id: "q2",
rowId: 2,
title: "Question 2?",
description: "Further information for Q2",
categoryByCategoryRowId: null,
@ -29,6 +31,7 @@ export const questionNodesMock: Array<BasicQuestionResponse> = [{
},
{
id: "q3",
rowId: 3,
title: "Question 3?",
description: null,
categoryByCategoryRowId: null,

View file

@ -18,6 +18,7 @@ interface GetQuestionsCategoryResponse {
export const BasicQuestionFragment = gql`
fragment BasicQuestionFragment on Question {
id
rowId
title
description
categoryByCategoryRowId {
@ -29,6 +30,7 @@ export const BasicQuestionFragment = gql`
export interface BasicQuestionResponse {
id: string,
rowId: number,
title: string,
description: string | null,
categoryByCategoryRowId: GetQuestionsCategoryResponse | null,

View file

@ -0,0 +1,84 @@
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) {
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

@ -3,6 +3,7 @@ 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({
@ -31,7 +32,9 @@ interface ButtonWithSpinnerProps {
loading?: boolean
type?: "button" | "submit",
fullWidth?: boolean,
autoFocus?: boolean
autoFocus?: boolean,
className?: string,
color?: PropTypes.Color,
}
export default function ButtonWithSpinner(props: ButtonWithSpinnerProps) {
@ -40,9 +43,9 @@ export default function ButtonWithSpinner(props: ButtonWithSpinnerProps) {
return (
<div className={classes.wrapper}>
<Button
className={classes.button}
className={`${classes.button} ${props.className}`}
variant="contained"
color="primary"
color={props.color || "primary"}
fullWidth={!!props.fullWidth}
type={props.type}
disabled={props.loading}

View file

@ -0,0 +1,69 @@
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() {
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

@ -0,0 +1,133 @@
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) {
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,70 @@
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,
loading?: boolean,
onSaveClick(text: string): void,
}
export default function EditAnswerText(props: EditAnswerTextSectionProps) {
const classes = useStyles();
const [answerText, setAnswerText] = useState<string>(props.remoteText);
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(props.remoteText)}
loading={props.loading}
>
Zurücksetzen
</ButtonWithSpinner>
<ButtonWithSpinner
loading={props.loading}
className={classes.button}
onClick={() => props.onSaveClick(answerText)}
>
Speichern
</ButtonWithSpinner>
</div>
</div>;
}

View file

@ -28,8 +28,10 @@ describe('The main page', () => {
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', () => {

View file

@ -24,19 +24,22 @@ const useStyles = makeStyles((theme) => ({
function Main() {
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/>
Zur <Link to={"/login"}>Login Seite</Link>
</Container>
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 (

View file

@ -1,6 +1,15 @@
import {Container} from "@material-ui/core";
import {Container, Paper, Typography} from "@material-ui/core";
import React from "react";
import {makeStyles} from "@material-ui/core/styles";
import {useQuery} from "@apollo/client";
import {
GET_ALL_QUESTION_ANSWERS,
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables
} from "../backend/queries/answer";
import {getJsonWebToken} from "../jwt/jwt";
import AccordionQuestionAnswer from "./AccordionQuestionAnswer";
import {CandidatePositionLegend} from "./CandidatePositionLegend";
const useStyles = makeStyles((theme) => ({
container: {
@ -8,21 +17,43 @@ const useStyles = makeStyles((theme) => ({
paddingBottom: theme.spacing(4),
flexDirection: 'column',
},
root: {
width: '100%',
padding: theme.spacing(1),
marginBottom: theme.spacing(3),
backgroundColor: theme.palette.background.default,
},
}));
export function MainPageCandidate() {
interface MainPageCandidateProps {
personRowId: number,
}
export function MainPageCandidate(props: MainPageCandidateProps) {
const personRowId = getJsonWebToken()?.person_row_id;
const questionAnswers = useQuery<GetAllQuestionAnswersResponse, GetAllQuestionAnswersVariables>(
GET_ALL_QUESTION_ANSWERS,
{
variables: {
personRowId,
}
}
).data?.allQuestions.nodes;
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
Under construction
<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>
</Container>
);
}
enum CandidatePosition {
positive = 0,
neutral = 1,
negative = 2,
skipped = 3
}

View file

@ -0,0 +1,45 @@
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) {
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>
);
}