From dfe2f51a5b75ece203f4ef6d38a3fbddd7604027 Mon Sep 17 00:00:00 2001 From: Albin Henriksson <albhe428@student.liu.se> Date: Thu, 20 May 2021 14:32:05 +0000 Subject: [PATCH] Resolve "Add pair match questions" --- client/src/interfaces/ApiModels.ts | 6 +- .../PresentationEditorPage.tsx | 2 +- .../components/QuestionComponentDisplay.tsx | 34 +- .../components/SlideDisplay.tsx | 13 +- .../components/SlideSettings.tsx | 5 + .../components/TextComponentDisplay.tsx | 2 +- .../components/TextComponentEdit.tsx | 2 +- .../answerComponents/AnswerMatch.tsx | 134 ++++++++ .../answerComponents/AnswerMultiple.tsx | 6 +- .../answerComponents/AnswerSingle.tsx | 2 +- .../answerComponents/AnswerText.tsx | 2 +- .../components/answerComponents/styled.tsx | 40 +++ .../MatchAlternatives.tsx | 316 ++++++++++++++++++ .../MultipleChoiceAlternatives.tsx | 25 +- .../QuestionSettings.tsx | 2 +- .../SingleChoiceAlternatives.tsx | 14 +- .../slideSettingsComponents/SlideType.tsx | 41 ++- .../presentationEditor/components/styled.tsx | 6 +- client/src/pages/views/JudgeViewPage.tsx | 26 +- client/src/pages/views/OperatorViewPage.tsx | 2 +- .../views/components/JudgeScoreDisplay.tsx | 88 ++++- .../components/JudgeScoringInstructions.tsx | 1 - client/src/pages/views/components/Timer.tsx | 28 +- client/src/pages/views/components/styled.tsx | 6 +- client/src/utils/renderSlideIcon.tsx | 3 + server/app/apis/alternatives.py | 32 +- server/app/apis/misc.py | 6 +- server/app/apis/slides.py | 36 +- server/app/core/schemas.py | 6 +- server/app/database/controller/add.py | 25 +- server/app/database/controller/get.py | 9 +- server/app/database/controller/utils.py | 81 +++-- server/app/database/models.py | 21 +- server/populate.py | 4 +- server/tests/test_db.py | 15 +- 35 files changed, 832 insertions(+), 209 deletions(-) create mode 100644 client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx create mode 100644 client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 7b5617d8..d32cd65a 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -61,8 +61,10 @@ export interface Question extends NameID { export interface QuestionAlternative { id: number - text: string - value: number + alternative: string + alternative_order: number + correct: string + correct_order: number question_id: number } diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index a46262af..9b040b9d 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -131,7 +131,7 @@ const PresentationEditorPage: React.FC = () => { setSortedSlides(slidesCopy) if (draggedSlideId) { await axios - .put(`/api/competitions/${competitionId}/slides/${draggedSlideId}/order`, { order: result.destination.index }) + .put(`/api/competitions/${competitionId}/slides/${draggedSlideId}`, { order: result.destination.index }) .catch(console.log) } } diff --git a/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx index 91894c6f..75690ea6 100644 --- a/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx +++ b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx @@ -11,9 +11,10 @@ * @module */ -import { Card, Divider, ListItem, Typography } from '@material-ui/core' +import { AppBar, Card, Divider, Typography } from '@material-ui/core' import React from 'react' import { useAppSelector } from '../../../hooks' +import AnswerMatch from './answerComponents/AnswerMatch' import AnswerMultiple from './answerComponents/AnswerMultiple' import AnswerSingle from './answerComponents/AnswerSingle' import AnswerText from './answerComponents/AnswerText' @@ -80,21 +81,34 @@ const QuestionComponentDisplay = ({ variant, currentSlideId }: QuestionComponent ) } return - + case 'Match': + if (activeSlide) { + return ( + <AnswerMatch + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return default: break } } return ( - <Card style={{ maxHeight: '100%', overflowY: 'auto' }}> - <ListItem> - <Center style={{ justifyContent: 'space-evenly' }}> - <Typography>Poäng: {total_score}</Typography> - <Typography>{questionName}</Typography> - <Typography>Timer: {timer}</Typography> - </Center> - </ListItem> + <Card elevation={4} style={{ maxHeight: '100%', overflowY: 'auto' }}> + <AppBar position="relative"> + <div style={{ display: 'flex', height: 60 }}> + <Center style={{ alignItems: 'center' }}> + <Typography variant="h5">{questionName}</Typography> + </Center> + </div> + <div style={{ position: 'fixed', right: 5, top: 14, display: 'flex', alignItems: 'center' }}> + <Typography variant="h5">{total_score}p</Typography> + </div> + </AppBar> <Divider /> {getAlternatives()} </Card> diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx index 897a321c..b6505eed 100644 --- a/client/src/pages/presentationEditor/components/SlideDisplay.tsx +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -1,4 +1,5 @@ -import { Typography } from '@material-ui/core' +import { Card, Typography } from '@material-ui/core' +import TimerIcon from '@material-ui/icons/Timer' import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { getTypes } from '../../../actions/typesAction' import { useAppDispatch, useAppSelector } from '../../../hooks' @@ -60,11 +61,15 @@ const SlideDisplay = ({ variant, activeViewTypeId, currentSlideId }: SlideDispla <SlideEditorContainerRatio> <SlideEditorPaper ref={editorPaperRef}> <SlideDisplayText $scale={scale}> - {variant === 'editor' && slide?.timer ? `Tid kvar: ${slide?.timer}` : ''} - {variant === 'presentation' && <Timer />} + {slide?.timer && ( + <Card style={{ display: 'flex', alignItems: 'center', padding: 10 }}> + <TimerIcon fontSize="large" /> + <Timer variant={variant} /> + </Card> + )} </SlideDisplayText> <SlideDisplayText $scale={scale} $right> - {slide && `Sida: ${slide?.order + 1} / ${totalSlides}`} + <Card style={{ padding: 10 }}>{slide && `${slide?.order + 1} / ${totalSlides}`}</Card> </SlideDisplayText> {(competitionBackgroundImage || slideBackgroundImage) && ( <img diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index 6b4a8d5e..68abd240 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -7,6 +7,7 @@ import { useAppSelector } from '../../../hooks' import BackgroundImageSelect from './BackgroundImageSelect' import Images from './slideSettingsComponents/Images' import Instructions from './slideSettingsComponents/Instructions' +import MatchAlternatives from './slideSettingsComponents/MatchAlternatives' import MultipleChoiceAlternatives from './slideSettingsComponents/MultipleChoiceAlternatives' import QuestionSettings from './slideSettingsComponents/QuestionSettings' import SingleChoiceAlternatives from './slideSettingsComponents/SingleChoiceAlternatives' @@ -54,6 +55,10 @@ const SlideSettings: React.FC = () => { <SingleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> )} + {activeSlide?.questions[0]?.type_id === 5 && ( + <MatchAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} + {activeSlide && ( <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> )} diff --git a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx index b2e11200..5cc87ec9 100644 --- a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ImageComponent, TextComponent } from '../../../interfaces/ApiModels' +import { TextComponent } from '../../../interfaces/ApiModels' type TextComponentDisplayProps = { component: TextComponent diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx index 61dc1284..a42bdb03 100644 --- a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx @@ -37,7 +37,7 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { setTimerHandle( window.setTimeout(async () => { await axios.put(`/api/competitions/${competitionId}/slides/${activeSlideId}/components/${component.id}`, { - text: newText, + alternative: newText, }) dispatch(getEditorCompetition(competitionId)) }, 250) diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx new file mode 100644 index 00000000..d2d6c3e2 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx @@ -0,0 +1,134 @@ +/** + * What it is: + * Contains the component for the multiple choice question type ("Kryssfråga") + * which is displayed in the participant view in the editor and presentation. + * This is a part of a question component which the users will interact with to answer multiple choice questions. + * The participants get multiple alternatives and can mark multiple of these alternatives as correct. + * + * How it's used: + * This file is used when a question component is to be rendered which only happens in QuestionComponentDisplay.tsx. + * For more information read the documentation of that file. + * + * @module + */ + +import { ListItemText, Typography } from '@material-ui/core' +import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown' +import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp' +import SyncAltIcon from '@material-ui/icons/SyncAlt' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, Clickable } from '../styled' +import { MatchButtonContainer, MatchCard, MatchContainer, MatchCorrectContainer, MatchIconContainer } from './styled' + +type AnswerMultipleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerMatch = ({ variant, activeSlide, competitionId }: AnswerMultipleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const [sortedAlternatives, setSortedAlternatives] = useState<QuestionAlternative[]>([]) + const [sortedAnswers, setSortedAnswers] = useState<QuestionAlternative[]>([]) + const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + + useEffect(() => { + if (activeSlide) { + setSortedAlternatives([ + ...activeSlide.questions[0].alternatives.sort((a, b) => (a.alternative_order > b.alternative_order ? 1 : -1)), + ]) + setSortedAnswers([ + ...activeSlide.questions[0].alternatives.sort((a, b) => (a.correct_order > b.correct_order ? 1 : -1)), + ]) + } + }, [activeSlide]) + + useEffect(() => { + // Send the standard answers ( if the team choses to not move one of the answers ) + if (teamId && team?.question_alternative_answers.length === 0) { + activeSlide?.questions[0].alternatives.forEach((alternative) => { + const answer = activeSlide?.questions[0].alternatives.find( + (alt) => alternative.alternative_order === alt.correct_order + ) + axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}`, { + answer: `${alternative.alternative} - ${answer?.correct}`, + }) + .then(() => { + dispatch(getPresentationCompetition(competitionId)) + }) + .catch(console.log) + }) + } + }, [teamId]) + + const onMove = async (previousIndex: number, resultIndex: number) => { + // moved outside the list + if (resultIndex < 0 || resultIndex >= sortedAnswers.length || variant !== 'presentation') return + const answersCopy = [...sortedAnswers] + const [removed] = answersCopy.splice(previousIndex, 1) + answersCopy.splice(resultIndex, 0, removed) + setSortedAnswers(answersCopy) + + sortedAlternatives.forEach((alternative, index) => { + const answeredText = answersCopy[index].correct + if (!activeSlide) return + axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}`, { + answer: `${alternative.alternative} - ${answeredText}`, + }) + .catch(console.log) + }) + } + + return ( + <> + <Center> + <ListItemText secondary="Para ihop de alternativ som hör ihop:" /> + </Center> + <MatchContainer> + <div style={{ flexDirection: 'column', marginRight: 20 }}> + {sortedAlternatives.map((alternative, index) => ( + <MatchCard key={alternative.id} elevation={4}> + <Typography id="outlined-basic">{alternative.alternative}</Typography> + </MatchCard> + ))} + </div> + + <div style={{ flexDirection: 'column', marginRight: 20 }}> + {sortedAlternatives.map((alternative, index) => ( + <MatchIconContainer key={alternative.id}> + <SyncAltIcon /> + </MatchIconContainer> + ))} + </div> + + <div style={{ flexDirection: 'column' }}> + {sortedAnswers.map((alternative, index) => ( + <MatchCard key={alternative.id} elevation={4}> + <MatchCorrectContainer> + <Typography id="outlined-basic">{alternative.correct}</Typography> + </MatchCorrectContainer> + <MatchButtonContainer> + <Clickable> + <KeyboardArrowUpIcon onClick={() => onMove(index, index - 1)} /> + </Clickable> + <Clickable> + <KeyboardArrowDownIcon onClick={() => onMove(index, index + 1)} /> + </Clickable> + </MatchButtonContainer> + </MatchCard> + ))} + </div> + </MatchContainer> + </> + ) +} + +export default AnswerMatch diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx index 0dd7efcf..5868ef11 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx @@ -50,10 +50,6 @@ const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleP if (!activeSlide) { return } - - //const checkedValue = checked ? 1 : 0 - //const correctAnswer = checkedValue === alternative.value ? 1 : 0 - const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}` const payload = { answer: checked ? 1 : 0, @@ -97,7 +93,7 @@ const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleP checked={decideChecked(alt)} onChange={(event: any) => updateAnswer(alt, event.target.checked)} /> - <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + <Typography style={{ wordBreak: 'break-all' }}>{alt.alternative}</Typography> </ListItem> </div> ))} diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx index 514200d1..1bd16da5 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx @@ -110,7 +110,7 @@ const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps <div key={alt.id}> <ListItem divider> {renderRadioButton(alt)} - <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + <Typography style={{ wordBreak: 'break-all' }}>{alt.alternative}</Typography> </ListItem> </div> ))} diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx index e96f65bc..f3dccfd5 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx @@ -48,7 +48,7 @@ const AnswerText = ({ activeSlide, competitionId }: AnswerTextProps) => { const alternative = activeSlide.questions[0].alternatives[0] const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/question_alternatives/${alternative.id}` await axios - .put(url, { answer: answer }) + .put(url, { answer }) .then(() => { dispatch(getPresentationCompetition(competitionId)) }) diff --git a/client/src/pages/presentationEditor/components/answerComponents/styled.tsx b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx index 9140e3c2..109776fe 100644 --- a/client/src/pages/presentationEditor/components/answerComponents/styled.tsx +++ b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx @@ -1,5 +1,45 @@ +import { Card } from '@material-ui/core' import styled from 'styled-components' export const AnswerTextFieldContainer = styled.div` height: calc(100% - 90px); ` +export const MatchContainer = styled.div` + margin-bottom: 50px; + margin-top: 10px; + display: flex; + justify-content: center; +` + +export const MatchCard = styled(Card)` + display: flex; + align-items: center; + justify-content: center; + height: 80px; + min-width: 150px; + margin-bottom: 5px; +` + +export const MatchIconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 80px; + margin-bottom: 5px; +` + +export const MatchButtonContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + margin-top: 10px; + margin-bottom: 10px; + margin-right: 5px; +` + +export const MatchCorrectContainer = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: center; +` diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx new file mode 100644 index 00000000..0a944000 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx @@ -0,0 +1,316 @@ +/** + * Lets a competition creator add, remove and handle alternatives for single choice questions ("Alternativfråga") in the slide settings panel. + * + * @module + */ + +import { + AppBar, + Button, + Card, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + ListItem, + ListItemText, + Tab, + Tabs, + Typography, +} from '@material-ui/core' +import ClearIcon from '@material-ui/icons/Clear' +import DragIndicatorIcon from '@material-ui/icons/DragIndicator' +import axios from 'axios' +import React, { useEffect } from 'react' +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, SettingsList } from '../styled' + +type SingleChoiceAlternativeProps = { + activeSlide: RichSlide + competitionId: string +} + +interface AlternativeUpdate { + alternative?: string + alternative_order?: string + correct?: string + correct_order?: string +} + +const MatchAlternatives = ({ activeSlide, competitionId }: SingleChoiceAlternativeProps) => { + const dispatch = useAppDispatch() + const [dialogOpen, setDialogOpen] = React.useState(false) + const [selectedTab, setSelectedTab] = React.useState(0) + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + // Locally stored sorted versions of alternatives to make the sorting smoother, and not have to wait on backend + const [alternativesSortedByAlternative, setAlternativesSortedByAlternative] = React.useState<QuestionAlternative[]>( + [] + ) + const [alternativesSortedByCorrect, setAlternativesSortedByCorrect] = React.useState<QuestionAlternative[]>([]) + useEffect(() => { + if (!activeSlide?.questions[0].alternatives) return + setAlternativesSortedByAlternative([ + ...activeSlide?.questions[0].alternatives.sort((a, b) => (a.alternative_order > b.alternative_order ? 1 : -1)), + ]) + setAlternativesSortedByCorrect([ + ...activeSlide?.questions[0].alternatives.sort((a, b) => (a.correct_order > b.correct_order ? 1 : -1)), + ]) + }, [activeSlide]) + + const onDragEnd = async (result: DropResult, orderType: 'alternative_order' | 'correct_order') => { + // dropped outside the list or same place + if (!result.destination || result.destination.index === result.source.index) return + + const draggedIndex = result.source.index + const draggedAlternativeId = activeSlide?.questions[0].alternatives.find((alt) => alt[orderType] === draggedIndex) + ?.id + if (orderType === 'alternative_order') { + const alternativesCopy = [...alternativesSortedByAlternative] + const [removed] = alternativesCopy.splice(draggedIndex, 1) + alternativesCopy.splice(result.destination.index, 0, removed) + setAlternativesSortedByAlternative(alternativesCopy) + } else { + const alternativesCopy = [...alternativesSortedByCorrect] + const [removed] = alternativesCopy.splice(draggedIndex, 1) + alternativesCopy.splice(result.destination.index, 0, removed) + setAlternativesSortedByCorrect(alternativesCopy) + } + if (!draggedAlternativeId) return + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${draggedAlternativeId}`, + { + [orderType]: result.destination.index, + } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const updateAlternative = async (alternative_id: number, alternativeUpdate: AlternativeUpdate) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates filter and api 250ms after last input was made + setTimerHandle( + window.setTimeout(() => { + if (activeSlide && activeSlide.questions[0]) { + axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, + alternativeUpdate + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + }, 250) + ) + } + + const addAlternative = async () => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const deleteAlternative = async (alternative_id: number) => { + if (activeSlide?.questions[0]) { + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Svarsalternativ" /> + </Center> + </ListItem> + {activeSlide?.questions?.[0]?.alternatives?.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <Typography> + {alt.alternative} | {alt.correct} + </Typography> + </ListItem> + </div> + ))} + <Dialog + fullWidth + open={dialogOpen} + onClose={() => console.log('close')} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">Redigera para ihop-alternativ</DialogTitle> + <DialogContent style={{ height: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> + <AppBar position="relative"> + <Tabs value={selectedTab} onChange={(event, selectedTab) => setSelectedTab(selectedTab)} centered> + <Tab label="Lag" /> + <Tab label="Facit" color="primary" /> + </Tabs> + </AppBar> + + {selectedTab === 0 && ( + <div style={{ marginBottom: 50, marginTop: 10 }}> + <div style={{ display: 'flex', justifyContent: 'center' }}> + {activeSlide?.questions[0].alternatives.length !== 0 && ( + <DialogContentText>Para ihop alternativen som de kommer se ut för lagen.</DialogContentText> + )} + {activeSlide?.questions[0].alternatives.length === 0 && ( + <DialogContentText> + Det finns inga alternativ, lägg till alternativ med knappen nedan. + </DialogContentText> + )} + </div> + <div style={{ display: 'flex' }}> + <DragDropContext onDragEnd={(result) => onDragEnd(result, 'alternative_order')}> + <div style={{ flexDirection: 'column' }}> + <Droppable droppableId="droppable1"> + {(provided) => ( + <div ref={provided.innerRef} {...provided.droppableProps}> + {alternativesSortedByAlternative.map((alternative, index) => ( + <Draggable draggableId={alternative.id.toString()} index={index} key={alternative.id}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <DragIndicatorIcon elevation={3} /> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.alternative} + onChange={(event) => + updateAlternative(alternative.id, { alternative: event.target.value }) + } + variant="outlined" + style={{ width: 200 }} + /> + </Card> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </div> + </DragDropContext> + + <DragDropContext onDragEnd={(result) => onDragEnd(result, 'correct_order')}> + <div style={{ flexDirection: 'column' }}> + <Droppable droppableId="droppable2"> + {(provided) => ( + <div ref={provided.innerRef} {...provided.droppableProps}> + {alternativesSortedByCorrect.map((alternative, index) => ( + <Draggable draggableId={alternative.id.toString()} index={index} key={alternative.id}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.correct} + onChange={(event) => + updateAlternative(alternative.id, { correct: event.target.value }) + } + variant="outlined" + /> + <DragIndicatorIcon elevation={3} /> + </Card> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </div> + </DragDropContext> + </div> + </div> + )} + + {selectedTab === 1 && ( + <div style={{ marginBottom: 50, marginTop: 10 }}> + <div style={{ display: 'flex', justifyContent: 'center' }}> + {activeSlide?.questions[0].alternatives.length !== 0 && ( + <DialogContentText>Editera svarsalternativen.</DialogContentText> + )} + {activeSlide?.questions[0].alternatives.length === 0 && ( + <DialogContentText> + Det finns inga alternativ, lägg till alternativ med knappen nedan. + </DialogContentText> + )} + </div> + {activeSlide?.questions?.[0]?.alternatives?.map((alternative, index) => ( + <div style={{ display: 'flex' }} key={alternative.id}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <IconButton size="small" onClick={() => deleteAlternative(alternative.id)}> + <ClearIcon color="error" /> + </IconButton> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.alternative} + onChange={(event) => updateAlternative(alternative.id, { alternative: event.target.value })} + variant="outlined" + style={{ width: 200 }} + /> + </Card> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.correct} + onChange={(event) => updateAlternative(alternative.id, { correct: event.target.value })} + variant="outlined" + style={{ width: 200 }} + /> + </Card> + </div> + ))} + </div> + )} + </DialogContent> + <DialogActions> + <Button variant="contained" autoFocus onClick={addAlternative} color="primary"> + Lägg till alternativ + </Button> + <Button variant="contained" autoFocus onClick={() => setDialogOpen(false)} color="secondary"> + Stäng + </Button> + </DialogActions> + </Dialog> + <ListItem button onClick={() => setDialogOpen(true)}> + <Center> + <AddButton variant="button">Redigera alternativ</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default MatchAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx index 60d741c8..17f09647 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx @@ -38,21 +38,20 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi /** * A checked checkbox is represented with 1 and an unchecked with 0. */ - const numberToBool = (num: number) => { - if (num === 0) return false + const stringToBool = (num: string) => { + if (num === '0') return false else return true } const updateAlternativeValue = async (alternative: QuestionAlternative) => { if (activeSlide && activeSlide.questions?.[0]) { - let newValue: number - if (alternative.value === 0) { - newValue = 1 - } else newValue = 0 - await axios - .put( + let newValue: string + if (alternative.correct === '0') { + newValue = '1' + } else newValue = '0' + await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, - { value: newValue } + { correct: newValue } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -66,7 +65,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, - { text: newText } + { alternative: newText } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -80,7 +79,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi await axios .post( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, - { text: '', value: 0 } + { correct: '0' } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -117,11 +116,11 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi <ListItem divider> <AlternativeTextField id="outlined-basic" - defaultValue={alt.text} + defaultValue={alt.alternative} onChange={(event) => updateAlternativeText(alt.id, event.target.value)} variant="outlined" /> - <GreenCheckbox checked={numberToBool(alt.value)} onChange={() => updateAlternativeValue(alt)} /> + <GreenCheckbox checked={stringToBool(alt.correct)} onChange={() => updateAlternativeValue(alt)} /> <Clickable> <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> </Clickable> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx index 6fe2d510..c2ea6e8e 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx @@ -97,7 +97,7 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) <Center> <SettingsItemContainer> <TextField - fullWidth={true} + fullWidth variant="outlined" placeholder="Antal poäng" helperText="Välj hur många poäng frågan ska ge för rätt svar. Lämna blank för att inte använda poängfunktionen" diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx index 16604f81..51de69da 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx @@ -28,19 +28,19 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl const updateAlternativeValue = async (alternative: QuestionAlternative) => { if (activeSlide && activeSlide.questions[0]) { // Remove check from previously checked alternative - const previousCheckedAltId = activeSlide.questions[0].alternatives.find((alt) => alt.value === 1)?.id + const previousCheckedAltId = activeSlide.questions[0].alternatives.find((alt) => alt.correct === '1')?.id if (previousCheckedAltId !== alternative.id) { if (previousCheckedAltId) { axios.put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${previousCheckedAltId}`, - { value: 0 } + { correct: '0' } ) } // Set new checked alternative await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, - { value: 1 } + { correct: '1' } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -55,7 +55,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, - { text: newText } + { alternative: newText } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -69,7 +69,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl await axios .post( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, - { text: '', value: 0 } + { correct: '0' } ) .then(() => { dispatch(getEditorCompetition(competitionId)) @@ -92,7 +92,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl } const renderRadioButton = (alt: QuestionAlternative) => { - if (alt.value) return <RadioButtonCheckedIcon onClick={() => updateAlternativeValue(alt)} /> + if (alt.correct === '1') return <RadioButtonCheckedIcon onClick={() => updateAlternativeValue(alt)} /> else return <RadioButtonUncheckedIcon onClick={() => updateAlternativeValue(alt)} /> } @@ -114,7 +114,7 @@ const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAl <ListItem divider> <AlternativeTextField id="outlined-basic" - defaultValue={alt.text} + defaultValue={alt.alternative} onChange={(event) => updateAlternativeText(alt.id, event.target.value)} variant="outlined" /> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx index e8faa58e..fdecde8e 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx @@ -78,18 +78,17 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(({ data }) => { dispatch(getEditorCompetition(competitionId)) - removeQuestionComponent().then(() => createQuestionComponent(data.id)) + removeQuestionComponent().then(() => { + //No question component for practical questions + if (selectedSlideType !== 2) createQuestionComponent(data.id) + }) }) .catch(console.log) if (selectedSlideType === 1) { // Add an alternative to text questions to allow giving answers. await axios .post( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}/alternatives`, - { - text: '', - value: 1, - } + `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}/alternatives` ) .then(({ data }) => { dispatch(getEditorCompetition(competitionId)) @@ -107,24 +106,19 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(({ data }) => { dispatch(getEditorCompetition(competitionId)) - createQuestionComponent(data.id) + //No question component for practical questions + if (selectedSlideType !== 2) createQuestionComponent(data.id) + if (selectedSlideType === 1) { + // Add an alternative to text questions to allow giving answers. + axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${data.id}/alternatives`) + .then(({ data }) => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } }) .catch(console.log) - if (selectedSlideType === 1) { - // Add an alternative to text questions to allow giving answers. - await axios - .post( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}/alternatives`, - { - text: '', - value: 1, - } - ) - .then(({ data }) => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } } } } @@ -176,6 +170,9 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { <MenuItem value={4} button onClick={() => openSlideTypeDialog(4)}> <Typography>Alternativfråga</Typography> </MenuItem> + <MenuItem value={5} button onClick={() => openSlideTypeDialog(5)}> + <Typography>Para ihop-fråga</Typography> + </MenuItem> </Select> </FormControl> diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 1a4f9169..106e360e 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -153,8 +153,8 @@ interface SlideDisplayTextProps { export const SlideDisplayText = styled(Typography)<SlideDisplayTextProps>` position: absolute; - top: 5px; - left: ${(props) => (props.$right ? undefined : 5)}px; - right: ${(props) => (props.$right ? 5 : undefined)}px; + top: 0px; + left: ${(props) => (props.$right ? undefined : 0)}px; + right: ${(props) => (props.$right ? 0 : undefined)}px; font-size: ${(props) => 24 * props.$scale}px; ` diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index a2874024..df80d4fc 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -124,17 +124,19 @@ const JudgeViewPage: React.FC = () => { <div className={classes.toolbar} /> <List> {slides.map((slide, index) => ( - <SlideListItem - selected={slide.order === currentSlide?.order} - onClick={() => handleSelectSlide(index)} - divider - button - key={slide.id} - style={{ border: 2, borderStyle: slide.id === operatorActiveSlideId ? 'dashed' : 'none' }} - > - {renderSlideIcon(slide)} - <ListItemText primary={`Sida ${slide.order + 1}`} /> - </SlideListItem> + <> + <SlideListItem + selected={slide.order === currentSlide?.order} + onClick={() => handleSelectSlide(index)} + button + key={slide.id} + style={{ border: 2, borderStyle: slide.id === operatorActiveSlideId ? 'dashed' : 'none' }} + > + {renderSlideIcon(slide)} + <ListItemText primary={`Sida ${slide.order + 1}`} /> + </SlideListItem> + <Divider /> + </> ))} </List> </LeftDrawer> @@ -153,7 +155,7 @@ const JudgeViewPage: React.FC = () => { </ScoreHeaderPaper> )} <ScoreHeaderPadding /> - <List style={{ overflowY: 'scroll', overflowX: 'hidden' }}> + <List style={{ overflowY: 'auto', overflowX: 'hidden' }}> {teams && teams.map((answer, index) => ( <div key={answer.name}> diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index 9dab453b..130240c2 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -317,7 +317,7 @@ const OperatorViewPage: React.FC = () => { color="primary" > <TimerIcon fontSize="large" /> - <Timer disableText /> + <Timer variant="presentation" /> </OperatorButton> </div> </Tooltip> diff --git a/client/src/pages/views/components/JudgeScoreDisplay.tsx b/client/src/pages/views/components/JudgeScoreDisplay.tsx index e213d844..40f545ec 100644 --- a/client/src/pages/views/components/JudgeScoreDisplay.tsx +++ b/client/src/pages/views/components/JudgeScoreDisplay.tsx @@ -1,4 +1,4 @@ -import { Box, Divider, Typography } from '@material-ui/core' +import { Box, Card, Divider, Typography } from '@material-ui/core' import axios from 'axios' import React from 'react' import { getPresentationCompetition } from '../../../actions/presentation' @@ -11,6 +11,7 @@ import { ScoreDisplayContainer, ScoreDisplayHeader, ScoreInput, + UnderlinedTypography, } from './styled' type ScoreDisplayProps = { @@ -25,9 +26,16 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { const activeQuestion = activeSlide.questions[0] const activeScore = currentTeam.question_scores.find((x) => x.question_id === activeQuestion?.id) - const questionMaxScore = activeQuestion?.total_score - const scores = currentTeam.question_scores.map((questionAnswer) => questionAnswer.score) + const questions = useAppSelector((state) => state.presentation.competition.slides.map((slide) => slide.questions[0])) + const teamScores = [...currentTeam.question_scores.map((score) => score)] + const scores: (number | undefined)[] = [] + for (const question of questions) { + const correctTeamScore = teamScores.find((score) => question && score.question_id === question.id) + if (correctTeamScore !== undefined) { + scores.push(correctTeamScore.score) + } else scores.push(undefined) + } const handleEditScore = async (newScore: number, questionId: number) => { await axios .put(`/api/competitions/${currentCompetititonId}/teams/${currentTeam.id}/answers/question_scores/${questionId}`, { @@ -36,6 +44,14 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { .then(() => dispatch(getPresentationCompetition(currentCompetititonId.toString()))) } + const sumTwoScores = (a: number | undefined, b: number | undefined) => { + let aValue = 0 + let bValue = 0 + aValue = a ? a : 0 + bValue = b ? b : 0 + return aValue + bValue + } + const getAnswers = () => { const result: string[] = [] if (!activeQuestion) { @@ -46,11 +62,11 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { if (!ans) { continue } - if (activeQuestion.type_id === 1) { - // Text question + if (activeQuestion.type_id === 1 || activeQuestion.type_id === 5) { + // Text question or match question result.push(ans.answer) } else if (+ans.answer > 0) { - result.push(alt.text) + result.push(alt.alternative) } } return result @@ -62,9 +78,12 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { return result } for (const alt of activeQuestion.alternatives) { - if (activeQuestion.type_id !== 1 && +alt.value > 0) { + // Match question + if (activeQuestion.type_id === 5) { + result.push(`${alt.alternative} - ${alt.correct}`) + } else if (activeQuestion.type_id !== 1 && +alt.correct > 0) { // Not text question and correct answer - result.push(alt.text) + result.push(alt.alternative) } } return result @@ -83,19 +102,57 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { defaultValue={0} value={activeScore ? activeScore.score : 0} inputProps={{ style: { fontSize: 20 } }} - InputProps={{ disableUnderline: true, inputProps: { min: 0, max: questionMaxScore } }} + InputProps={{ disableUnderline: true, inputProps: { min: 0 } }} type="number" onChange={(event) => handleEditScore(+event.target.value, activeQuestion.id)} /> )} </ScoreDisplayHeader> - <Typography variant="h6">Alla poäng: [ {scores.map((score) => `${score} `)}]</Typography> - <Typography variant="h6">Total poäng: {scores.reduce((a, b) => a + b, 0)}</Typography> - + <Typography variant="h6"> + Sidor: + <div style={{ display: 'flex' }}> + {questions.map((question, index) => ( + <Card + key={index} + elevation={2} + style={{ + width: 25, + height: 25, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }} + > + {index + 1} + </Card> + ))} + </div> + Poäng: + <div style={{ display: 'flex' }}> + {scores.map((score, index) => ( + <Card + key={index} + elevation={2} + style={{ + width: 25, + height: 25, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }} + > + {questions[index] ? score : '-'} + </Card> + ))} + </div> + Total poäng: {scores.reduce((a, b) => sumTwoScores(a, b), 0)} + </Typography> <AnswersDisplay> <Answers> <Divider /> - <Typography variant="body1">Lagets svar:</Typography> + <UnderlinedTypography variant="body1">Lagets svar:</UnderlinedTypography> {activeQuestion && ( <AnswerContainer> {getAnswers().map((v, k) => ( @@ -109,7 +166,9 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { <Answers> <Divider /> - <Typography variant="body1">Korrekta svar:</Typography> + {activeQuestion && activeQuestion.type_id !== 1 && ( + <UnderlinedTypography variant="body1">Korrekta svar:</UnderlinedTypography> + )} {activeQuestion && ( <AnswerContainer> {getAlternatives().map((v, k) => ( @@ -121,7 +180,6 @@ const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { )} </Answers> </AnswersDisplay> - {!activeQuestion && <Typography variant="body1">Inget svar</Typography>} </ScoreDisplayContainer> ) diff --git a/client/src/pages/views/components/JudgeScoringInstructions.tsx b/client/src/pages/views/components/JudgeScoringInstructions.tsx index 86d8eaf1..f8c21667 100644 --- a/client/src/pages/views/components/JudgeScoringInstructions.tsx +++ b/client/src/pages/views/components/JudgeScoringInstructions.tsx @@ -8,7 +8,6 @@ type JudgeScoringInstructionsProps = { } const JudgeScoringInstructions = ({ question }: JudgeScoringInstructionsProps) => { - console.log(question) return ( <JudgeScoringInstructionsContainer elevation={3}> <ScoringInstructionsInner> diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx index 6f4add03..bb65eca4 100644 --- a/client/src/pages/views/components/Timer.tsx +++ b/client/src/pages/views/components/Timer.tsx @@ -3,24 +3,36 @@ import { setPresentationTimer } from '../../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../../hooks' type TimerProps = { - disableText?: boolean + variant: 'editor' | 'presentation' } -const Timer = ({ disableText }: TimerProps) => { +const Timer = ({ variant }: TimerProps) => { const dispatch = useAppDispatch() const timer = useAppSelector((state) => state.presentation.timer) const [remainingTimer, setRemainingTimer] = useState<number>(0) + const remainingSeconds = remainingTimer / 1000 + const remainingWholeSeconds = Math.floor(remainingSeconds % 60) + // Add a 0 before the seconds if it's lower than 10 + const remainingDisplaySeconds = `${remainingWholeSeconds < 10 ? '0' : ''}${remainingWholeSeconds}` + + const remainingMinutes = Math.floor(remainingSeconds / 60) % 60 + // Add a 0 before the minutes if it's lower than 10 + const remainingDisplayMinutes = `${remainingMinutes < 10 ? '0' : ''}${remainingMinutes}` + + const displayTime = `${remainingDisplayMinutes}:${remainingDisplaySeconds}` const [timerIntervalId, setTimerIntervalId] = useState<NodeJS.Timeout | null>(null) - const slideTimer = useAppSelector( - (state) => - state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.timer - ) + const slideTimer = useAppSelector((state) => { + if (variant === 'presentation') + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.timer + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.timer + }) useEffect(() => { - if (slideTimer) setRemainingTimer(slideTimer) + if (slideTimer) setRemainingTimer(slideTimer * 1000) }, [slideTimer]) useEffect(() => { + if (variant === 'editor') return if (!timer.enabled) { if (timerIntervalId !== null) clearInterval(timerIntervalId) @@ -50,7 +62,7 @@ const Timer = ({ disableText }: TimerProps) => { ) }, [timer.enabled, slideTimer]) - return <>{`${!disableText ? 'Tid kvar:' : ''} ${Math.round(remainingTimer / 1000)}`}</> + return <>{slideTimer && displayTime}</> } export default Timer diff --git a/client/src/pages/views/components/styled.tsx b/client/src/pages/views/components/styled.tsx index e73b1463..602011d4 100644 --- a/client/src/pages/views/components/styled.tsx +++ b/client/src/pages/views/components/styled.tsx @@ -1,4 +1,4 @@ -import { Paper, TextField } from '@material-ui/core' +import { Paper, TextField, Typography } from '@material-ui/core' import styled from 'styled-components' export const SlideContainer = styled.div` @@ -61,3 +61,7 @@ export const Answers = styled.div` align-items: center; flex-direction: column; ` + +export const UnderlinedTypography = styled(Typography)` + text-decoration: underline; +` diff --git a/client/src/utils/renderSlideIcon.tsx b/client/src/utils/renderSlideIcon.tsx index 22f6405a..fe1d0040 100644 --- a/client/src/utils/renderSlideIcon.tsx +++ b/client/src/utils/renderSlideIcon.tsx @@ -3,6 +3,7 @@ import CheckBoxOutlinedIcon from '@material-ui/icons/CheckBoxOutlined' import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked' +import UnfoldMoreOutlinedIcon from '@material-ui/icons/UnfoldMoreOutlined' import React from 'react' import { RichSlide } from '../interfaces/ApiRichModels' @@ -17,6 +18,8 @@ export const renderSlideIcon = (slide: RichSlide) => { return <CheckBoxOutlinedIcon /> // multiple choice question case 4: return <RadioButtonCheckedIcon /> // single choice question + case 5: + return <UnfoldMoreOutlinedIcon /> // Match question } } else { return <InfoOutlinedIcon /> // information slide diff --git a/server/app/apis/alternatives.py b/server/app/apis/alternatives.py index 1827b358..d3ae5d9d 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -3,11 +3,14 @@ All API calls concerning question alternatives. Default route: /api/competitions/<competition_id>/slides/<slide_id>/questions/<question_id>/alternatives """ +from os import abort + import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAlternativeDTO from app.core.parsers import sentinel +from app.database.models import Question, QuestionAlternative from flask_restx import Resource, reqparse api = QuestionAlternativeDTO.api @@ -15,12 +18,14 @@ schema = QuestionAlternativeDTO.schema list_schema = QuestionAlternativeDTO.list_schema alternative_parser_add = reqparse.RequestParser() -alternative_parser_add.add_argument("text", type=str, required=True, location="json") -alternative_parser_add.add_argument("value", type=int, required=True, location="json") +alternative_parser_add.add_argument("alternative", type=str, default="", location="json") +alternative_parser_add.add_argument("correct", type=str, default="", location="json") alternative_parser_edit = reqparse.RequestParser() -alternative_parser_edit.add_argument("text", type=str, default=sentinel, location="json") -alternative_parser_edit.add_argument("value", type=int, default=sentinel, location="json") +alternative_parser_edit.add_argument("alternative", type=str, default=sentinel, location="json") +alternative_parser_edit.add_argument("alternative_order", type=int, default=sentinel, location="json") +alternative_parser_edit.add_argument("correct", type=str, default=sentinel, location="json") +alternative_parser_edit.add_argument("correct_order", type=int, default=sentinel, location="json") @api.route("") @@ -77,6 +82,25 @@ class QuestionAlternatives(Resource): question_id, alternative_id, ) + + new_alternative_order = args.pop("alternative_order") + if new_alternative_order is not sentinel and item.alternative_order != new_alternative_order: + if not (0 <= new_alternative_order < dbc.utils.count(QuestionAlternative, {"question_id": question_id})): + abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_alternative_order}'") + + item_question = dbc.get.one(Question, question_id) + dbc.utils.move_order( + item_question.alternatives, "alternative_order", item.alternative_order, new_alternative_order + ) + + new_correct_order = args.pop("correct_order") + if new_correct_order is not sentinel and item.correct_order != new_correct_order: + if not (0 <= new_correct_order < dbc.utils.count(QuestionAlternative, {"question_id": question_id})): + abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_correct_order}'") + + item_question = dbc.get.one(Question, question_id) + dbc.utils.move_order(item_question.alternatives, "correct_order", item.correct_order, new_correct_order) + item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/misc.py b/server/app/apis/misc.py index 552a0ad0..0f4aba74 100644 --- a/server/app/apis/misc.py +++ b/server/app/apis/misc.py @@ -97,7 +97,7 @@ class Statistics(Resource): def get(self): """ Gets statistics. """ - user_count = User.query.count() - competition_count = Competition.query.count() - region_count = City.query.count() + user_count = dbc.utils.count(User) + competition_count = dbc.utils.count(Competition) + region_count = dbc.utils.count(City) return {"users": user_count, "competitions": competition_count, "regions": region_count}, http_codes.OK diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index 7f322d52..654ee49f 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -8,8 +8,7 @@ import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import SlideDTO from app.core.parsers import sentinel -from app.database.controller.get import slide_count -from app.database.models import Competition +from app.database.models import Competition, Slide from flask_restx import Resource, reqparse from flask_restx.errors import abort @@ -21,6 +20,7 @@ slide_parser_edit = reqparse.RequestParser() slide_parser_edit.add_argument("order", type=int, default=sentinel, location="json") slide_parser_edit.add_argument("title", type=str, default=sentinel, location="json") slide_parser_edit.add_argument("timer", type=int, default=sentinel, location="json") +slide_parser_edit.add_argument("order", type=int, default=sentinel, location="json") slide_parser_edit.add_argument("background_image_id", default=sentinel, type=int, location="json") @@ -59,6 +59,15 @@ class Slides(Resource): args = slide_parser_edit.parse_args(strict=True) item_slide = dbc.get.slide(competition_id, slide_id) + + new_order = args.pop("order") + if new_order is not sentinel and item_slide.order != new_order: + if not (0 <= new_order < dbc.utils.count(Slide, {"competition_id": competition_id})): + abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_order}'") + + item_competition = dbc.get.one(Competition, competition_id) + dbc.utils.move_order(item_competition.slides, "order", item_slide.order, new_order) + item_slide = dbc.edit.default(item_slide, **args) return item_response(schema.dump(item_slide)) @@ -73,29 +82,6 @@ class Slides(Resource): return {}, codes.NO_CONTENT -@api.route("/<slide_id>/order") -@api.param("competition_id, slide_id") -class SlideOrder(Resource): - @protect_route(allowed_roles=["*"]) - def put(self, competition_id, slide_id): - """ Edits the specified slide order using the provided arguments. """ - - args = slide_parser_edit.parse_args(strict=True) - new_order = args.get("order") - - item_slide = dbc.get.slide(competition_id, slide_id) - - if new_order == item_slide.order: - return item_response(schema.dump(item_slide)) - - if not (0 <= new_order < dbc.get.slide_count(competition_id)): - abort(codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_order}'") - - item_competition = dbc.get.one(Competition, competition_id) - dbc.utils.move_slides(item_competition, item_slide.order, new_order) - return item_response(schema.dump(item_slide)) - - @api.route("/<slide_id>/copy") @api.param("competition_id,slide_id") class SlideCopy(Resource): diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 64d9ca18..b5206d59 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -90,8 +90,10 @@ class QuestionAlternativeSchema(BaseSchema): model = models.QuestionAlternative id = ma.auto_field() - text = ma.auto_field() - value = ma.auto_field() + alternative = ma.auto_field() + alternative_order = ma.auto_field() + correct = ma.auto_field() + correct_order = ma.auto_field() question_id = ma.auto_field() diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 799fe4cc..70c2a8c6 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -5,8 +5,8 @@ This file contains functionality to add data to the database. import os import app.core.http_codes as codes +import app.database.controller as dbc from app.core import db -from app.database.controller import get, utils from app.database.models import ( Blacklist, City, @@ -80,7 +80,7 @@ def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, * item.text = data.get("text") elif type_id == IMAGE_COMPONENT_ID: if not copy: # Scale image if adding a new one, a copied image should keep it's size - item_image = get.one(Media, data["media_id"]) + item_image = dbc.get.one(Media, data["media_id"]) filename = item_image.filename path = os.path.join( current_app.config["UPLOADED_PHOTOS_DEST"], @@ -109,14 +109,14 @@ def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, * else: abort(codes.BAD_REQUEST, f"Invalid type_id{type_id}") - item = utils.commit_and_refresh(item) + item = dbc.utils.commit_and_refresh(item) return item def code(view_type_id, competition_id=None, team_id=None): """ Adds a code to the database using the provided arguments. """ - code_string = utils.generate_unique_code() + code_string = dbc.utils.generate_unique_code() return db_add(Code(code_string, view_type_id, competition_id, team_id)) @@ -135,7 +135,7 @@ def slide(competition_id): """ Adds a slide to the provided competition. """ # Get the last order from given competition - order = Slide.query.filter(Slide.competition_id == competition_id).count() + order = dbc.utils.count(Slide, {"competition_id": competition_id}) # Add slide item_slide = db_add(Slide(order, competition_id)) @@ -143,7 +143,7 @@ def slide(competition_id): # Add default question question(f"Fråga {item_slide.order + 1}", 10, 1, item_slide.id) - item_slide = utils.refresh(item_slide) + item_slide = dbc.utils.refresh(item_slide) return item_slide @@ -151,12 +151,12 @@ def slide_without_question(competition_id): """ Adds a slide to the provided competition. """ # Get the last order from given competition - order = Slide.query.filter(Slide.competition_id == competition_id).count() + order = dbc.utils.count(Slide, {"competition_id": competition_id}) # Add slide item_slide = db_add(Slide(order, competition_id)) - item_slide = utils.refresh(item_slide) + item_slide = dbc.utils.refresh(item_slide) return item_slide @@ -179,7 +179,7 @@ def competition(name, year, city_id): # Add code for Operator view code(4, item_competition.id) - item_competition = utils.refresh(item_competition) + item_competition = dbc.utils.refresh(item_competition) return item_competition @@ -202,7 +202,7 @@ def _competition_no_slides(name, year, city_id, font=None): # Add code for Operator view code(4, item_competition.id) - item_competition = utils.refresh(item_competition) + item_competition = dbc.utils.refresh(item_competition) return item_competition @@ -276,13 +276,14 @@ def question(name, total_score, type_id, slide_id, correcting_instructions=None) return db_add(Question(name, total_score, type_id, slide_id, correcting_instructions)) -def question_alternative(text, value, question_id): +def question_alternative(alternative, correct, question_id): """ Adds a question alternative to the specified question using the provided arguments. """ - return db_add(QuestionAlternative(text, value, question_id)) + order = dbc.utils.count(QuestionAlternative, {"question_id": question_id}) + return db_add(QuestionAlternative(alternative, order, correct, order, question_id)) def question_score(score, question_id, team_id): diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index 5c075b34..3c982eec 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -2,6 +2,7 @@ This file contains functionality to get data from the database. """ +import app.database.controller as dbc from app.core import db from app.core import http_codes as codes from app.database.models import ( @@ -57,7 +58,7 @@ def code_list(competition_id): def user_exists(email): """ Checks if an user has that email. """ - return User.query.filter(User.email == email).count() > 0 + return dbc.utils.count(User, {"email": email}) > 0 def user_by_email(email): @@ -89,12 +90,6 @@ def slide_list(competition_id): return Slide.query.join(Competition, join_competition).filter(filters).all() -def slide_count(competition_id): - """ Gets the number of slides in the provided competition. """ - - return Slide.query.filter(Slide.competition_id == competition_id).count() - - ### Teams ### def team(competition_id, team_id): """ Gets the team object associated with the competition and team. """ diff --git a/server/app/database/controller/utils.py b/server/app/database/controller/utils.py index 9662b08e..f71b61e7 100644 --- a/server/app/database/controller/utils.py +++ b/server/app/database/controller/utils.py @@ -9,14 +9,15 @@ from app.database.models import Code from flask_restx import abort -def move_slides(item_competition, from_order, to_order): +def move_order(orders, order_key, from_order, to_order): """ - Move slide from from_order to to_order in item_competition. + Move key from from_order to to_order in db_item. See examples in + alternatives.py and slides.py. """ - num_slides = len(item_competition.slides) - assert 0 <= from_order < num_slides, "Invalid order to move from" - assert 0 <= to_order < num_slides, "Invalid order to move to" + num_orders = len(orders) + assert 0 <= from_order < num_orders, "Invalid order to move from" + assert 0 <= to_order < num_orders, "Invalid order to move to" # This function is sooo terrible, someone please tell me how to update # multiple values in the database at the same time with unique constraints. @@ -26,61 +27,75 @@ def move_slides(item_competition, from_order, to_order): # so 2 commits. # An example will follow the entire code to make it clear what it does - # Lets say we have 5 slides, and we want to move the slide at index 1 + # Lets say we have 5 orders, and we want to move the item at index 1 # to index 4. - # We begin with a list of slides with orders [0, 1, 2, 3, 4] - - slides = item_competition.slides + # We begin with a list of item with orders [0, 1, 2, 3, 4] change = 1 if to_order < from_order else -1 start_order = min(from_order, to_order) end_order = max(from_order, to_order) - # Move slides up 100 - for item_slide in slides: - item_slide.order += 100 + # Move orders up 100 + for item_with_order in orders: + setattr(item_with_order, order_key, getattr(item_with_order, order_key) + 100) - # Our slide orders now look like [100, 101, 102, 103, 104] + # Our items now look like [100, 101, 102, 103, 104] - # Move slides between from and to order either up or down, but minus in front - for item_slide in slides: - if start_order <= item_slide.order - 100 <= end_order: - item_slide.order = -(item_slide.order + change) + # Move orders between from and to order either up or down, but minus in front + for item_with_order in orders: + if start_order <= getattr(item_with_order, order_key) - 100 <= end_order: + setattr(item_with_order, order_key, -(getattr(item_with_order, order_key) + change)) - # Our slide orders now look like [100, -100, -101, -102, -103] + # Our items now look like [100, -100, -101, -102, -103] - # Find the slide that was to be moved and change it to correct order with minus in front - for item_slide in slides: - if item_slide.order == -(from_order + change + 100): - item_slide.order = -(to_order + 100) + # Find the item that was to be moved and change it to correct order with minus in front + for item_with_order in orders: + if getattr(item_with_order, order_key) == -(from_order + change + 100): + setattr(item_with_order, order_key, -(to_order + 100)) break - # Our slide orders now look like [100, -104, -101, -102, -103] + # Our items now look like [100, -104, -101, -102, -103] db.session.commit() # Negate all order so that they become positive - for item_slide in slides: - if start_order <= -(item_slide.order + 100) <= end_order: - item_slide.order = -(item_slide.order) + for item_with_order in orders: + if start_order <= -(getattr(item_with_order, order_key) + 100) <= end_order: + setattr(item_with_order, order_key, -getattr(item_with_order, order_key)) - # Our slide orders now look like [100, 104, 101, 102, 103] + # Our items now look like [100, 104, 101, 102, 103] - for item_slide in slides: - item_slide.order -= 100 + for item_with_order in orders: + setattr(item_with_order, order_key, getattr(item_with_order, order_key) - 100) - # Our slide orders now look like [0, 4, 1, 2, 3] + # Our items now look like [0, 4, 1, 2, 3] - # We have now successfully moved slide 1 to 4 + # We have now successfully moved item from order 1 to order 4 - return commit_and_refresh(item_competition) + db.session.commit() + + +def count(db_type, filter=None): + """ + Count number of db_type items that match all keys and values in filter. + + >>> count(User, {"city_id": 1}) # Get number of users with city_id equal to 1 + 5 + """ + + filter = filter or {} + query = db_type.query + for key, value in filter.items(): + query = query.filter(getattr(db_type, key) == value) + return query.count() def generate_unique_code(): """ Generates a unique competition code. """ code = generate_code_string() - while db.session.query(Code).filter(Code.code == code).count(): + + while count(Code, {"code": code}): code = generate_code_string() return code diff --git a/server/app/database/models.py b/server/app/database/models.py index f46e1cd1..7596ab6b 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -5,7 +5,8 @@ each other. """ from app.core import bcrypt, db -from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID +from app.database.types import (IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, + TEXT_COMPONENT_ID) from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property from sqlalchemy.orm import backref @@ -241,15 +242,23 @@ class QuestionAlternative(db.Model): Depend on table: Question. """ + __table_args__ = ( + db.UniqueConstraint("question_id", "alternative_order"), + db.UniqueConstraint("question_id", "correct_order"), + ) id = db.Column(db.Integer, primary_key=True) - text = db.Column(db.String(STRING_SIZE), nullable=False) - value = db.Column(db.Integer, nullable=False) + alternative = db.Column(db.String(STRING_SIZE), nullable=False) + alternative_order = db.Column(db.Integer) + correct = db.Column(db.String(STRING_SIZE), nullable=False) + correct_order = db.Column(db.Integer) question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) - def __init__(self, text, value, question_id): - self.text = text - self.value = value + def __init__(self, alternative, alternative_order, correct, correct_order, question_id): + self.alternative = alternative + self.alternative_order = alternative_order + self.correct = correct + self.correct_order = correct_order self.question_id = question_id diff --git a/server/populate.py b/server/populate.py index 34b05347..9ea096cd 100644 --- a/server/populate.py +++ b/server/populate.py @@ -11,7 +11,7 @@ from app.database.models import City, QuestionType, Role def create_default_items(): media_types = ["Image", "Video"] - question_types = ["Text", "Practical", "Multiple", "Single"] + question_types = ["Text", "Practical", "Multiple", "Single", "Match"] component_types = ["Text", "Image", "Question"] view_types = ["Team", "Judge", "Audience", "Operator"] @@ -77,7 +77,7 @@ def create_default_items(): """ for k in range(3): - dbc.add.question_alternative(f"Alternative {k}", 0, item_slide.questions[0].id) + dbc.add.question_alternative(f"Alternative {k}", f"Correct {k}", item_slide.questions[0].id) # Add text components # TODO: Add images as components diff --git a/server/tests/test_db.py b/server/tests/test_db.py index 0e028746..649ebbf1 100644 --- a/server/tests/test_db.py +++ b/server/tests/test_db.py @@ -193,21 +193,26 @@ def test_move_slides(client): dbc.add.slide(item_comp.id) # Move from beginning to end - item_comp = dbc.utils.move_slides(item_comp, 0, 9) + dbc.utils.move_order(item_comp.slides, "order", 0, 9) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [9, 0, 1, 2, 3, 4, 5, 6, 7, 8]) # Move from end to beginning - item_comp = dbc.utils.move_slides(item_comp, 9, 0) + dbc.utils.move_order(item_comp.slides, "order", 9, 0) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) # Move some things in the middle - item_comp = dbc.utils.move_slides(item_comp, 3, 7) + dbc.utils.move_order(item_comp.slides, "order", 3, 7) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 1, 2, 7, 3, 4, 5, 6, 8, 9]) - item_comp = dbc.utils.move_slides(item_comp, 1, 5) + dbc.utils.move_order(item_comp.slides, "order", 1, 5) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 5, 1, 7, 2, 3, 4, 6, 8, 9]) - item_comp = dbc.utils.move_slides(item_comp, 8, 2) + dbc.utils.move_order(item_comp.slides, "order", 8, 2) + dbc.utils.refresh(item_comp) assert_slide_order(item_comp, [0, 6, 1, 8, 3, 4, 5, 7, 2, 9]) -- GitLab