#14 Implement possibility to answer questions for canditates
This commit is contained in:
parent
6ea057a1cf
commit
5e219089f6
|
@ -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)
|
||||
|
|
121
redaktions-app/src/backend/mutations/answer.ts
Normal file
121
redaktions-app/src/backend/mutations/answer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>> =
|
|||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
|
95
redaktions-app/src/backend/queries/answer.ts
Normal file
95
redaktions-app/src/backend/queries/answer.ts
Normal 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,
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
84
redaktions-app/src/components/AccordionQuestionAnswer.tsx
Normal file
84
redaktions-app/src/components/AccordionQuestionAnswer.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
|
|
69
redaktions-app/src/components/CandidatePositionLegend.tsx
Normal file
69
redaktions-app/src/components/CandidatePositionLegend.tsx
Normal 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} />
|
||||
}
|
||||
}
|
133
redaktions-app/src/components/EditAnswerSection.tsx
Normal file
133
redaktions-app/src/components/EditAnswerSection.tsx
Normal 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>
|
||||
;
|
||||
}
|
70
redaktions-app/src/components/EditAnswerText.tsx
Normal file
70
redaktions-app/src/components/EditAnswerText.tsx
Normal 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>;
|
||||
}
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue