diff --git a/client/src/actions/competitionLogin.test.ts b/client/src/actions/competitionLogin.test.ts index 9a988c23fe798d161b5ce9db0d892968baea2e88..a95dd002436079daedf08ffd5d7cf480f18deee2 100644 --- a/client/src/actions/competitionLogin.test.ts +++ b/client/src/actions/competitionLogin.test.ts @@ -62,7 +62,7 @@ it('dispatches correct action when failing to log in user', async () => { console.log = jest.fn() const errorMessage = 'getting teams failed' ;(mockedAxios.post as jest.Mock).mockImplementation(() => { - return Promise.reject({ response: { data: errorMessage } }) + return Promise.reject({ response: { data: { message: errorMessage } } }) }) const store = mockStore({}) const history = createMemoryHistory() diff --git a/client/src/actions/competitionLogin.ts b/client/src/actions/competitionLogin.ts index cfc222b3afa0f9aa3f6135a2719160f515f095ba..42a050532ef5ebe571eeb6ab67a3d8eaf1fa0129 100644 --- a/client/src/actions/competitionLogin.ts +++ b/client/src/actions/competitionLogin.ts @@ -35,7 +35,14 @@ export const loginCompetition = (code: string, history: History, redirect: boole } }) .catch((err) => { - dispatch({ type: Types.SET_COMPETITION_LOGIN_ERRORS, payload: err && err.response && err.response.data }) + let errorMessage = err?.response?.data?.message + if (err?.response?.status === 401) { + errorMessage = 'Inkorrekt kod. Dubbelkolla koden och försök igen.' + } + if (err?.response?.status === 404) { + errorMessage = 'En tävling med den koden existerar inte. Dubbelkolla koden och försök igen.' + } + dispatch({ type: Types.SET_COMPETITION_LOGIN_ERRORS, payload: errorMessage }) console.log(err) }) } diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 9bcd24c1b916ff925a9472dffd8b2fabd5395bfb..5ea091a0217bf973ec9630e515350bf6cd2820a5 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -36,7 +36,7 @@ export interface Slide { competition_id: number id: number order: number - timer: number + timer: number | null title: string background_image?: Media } @@ -54,7 +54,7 @@ export interface Team extends NameID { export interface Question extends NameID { slide_id: number - total_score: number + total_score: number | null type_id: number correcting_instructions: string } diff --git a/client/src/interfaces/ApiRichModels.ts b/client/src/interfaces/ApiRichModels.ts index 253565a40e4ca4c8d0b1cc3597a58d5eb91cf2ec..9eac01813c970e7500f3d4eaadf98e4a22e3acc5 100644 --- a/client/src/interfaces/ApiRichModels.ts +++ b/client/src/interfaces/ApiRichModels.ts @@ -13,7 +13,7 @@ export interface RichCompetition { export interface RichSlide { id: number order: number - timer: number + timer: number | null title: string competition_id: number background_image?: Media diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index 8d1935576ce23afe95682127f8072350a9627ea3..7b132f2ab34deeaf83d7ec3a8d39f2a7e428d56a 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -20,6 +20,7 @@ import React, { useEffect } from 'react' import { Link, Route, Switch, useRouteMatch } from 'react-router-dom' import { getCities } from '../../actions/cities' import { setEditorLoading } from '../../actions/competitions' +import { setEditorSlideId } from '../../actions/editor' import { getRoles } from '../../actions/roles' import { getStatistics } from '../../actions/statistics' import { getTypes } from '../../actions/typesAction' @@ -75,6 +76,7 @@ const AdminView: React.FC = () => { dispatch(getTypes()) dispatch(getStatistics()) dispatch(setEditorLoading(true)) + dispatch(setEditorSlideId(-1)) }, []) const menuAdminItems = [ diff --git a/client/src/pages/admin/competitions/AddCompetition.tsx b/client/src/pages/admin/competitions/AddCompetition.tsx index 165d17d8dbb03305013b339001d8147ac57365d7..f2cdd1d1cb35eb76e4cd9ec0c696b6a8a088cee4 100644 --- a/client/src/pages/admin/competitions/AddCompetition.tsx +++ b/client/src/pages/admin/competitions/AddCompetition.tsx @@ -74,9 +74,11 @@ const AddCompetition: React.FC = (props: any) => { // if the post request fails .catch(({ response }) => { console.warn(response.data) - if (response.data && response.data.message) + if (response?.status === 409) + actions.setFieldError('error', 'En tävling med det namnet finns redan, välj ett nytt namn och försök igen') + else if (response.data && response.data.message) actions.setFieldError('error', response.data && response.data.message) - else actions.setFieldError('error', 'Something went wrong, please try again') + else actions.setFieldError('error', 'Någonting gick fel, försök igen') }) .finally(() => { actions.setSubmitting(false) @@ -184,7 +186,7 @@ const AddCompetition: React.FC = (props: any) => { fullWidth variant="contained" color="secondary" - disabled={!formik.isValid || !formik.values.model?.name || !formik.values.model?.city} + disabled={!formik.isValid || !formik.values.model?.name || !selectedCity} > Skapa </Button> diff --git a/client/src/pages/admin/regions/Regions.tsx b/client/src/pages/admin/regions/Regions.tsx index 57f4c101e829c8cd57dd0f72bdb8309772b8b26a..0458266fa7a9bb1fb26e2949e33c7cc2192f78fa 100644 --- a/client/src/pages/admin/regions/Regions.tsx +++ b/client/src/pages/admin/regions/Regions.tsx @@ -1,4 +1,4 @@ -import { Button, Menu, Typography } from '@material-ui/core' +import { Button, Menu, Snackbar, Typography } from '@material-ui/core' import Paper from '@material-ui/core/Paper' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import Table from '@material-ui/core/Table' @@ -8,6 +8,7 @@ import TableContainer from '@material-ui/core/TableContainer' import TableHead from '@material-ui/core/TableHead' import TableRow from '@material-ui/core/TableRow' import MoreHorizIcon from '@material-ui/icons/MoreHoriz' +import { Alert } from '@material-ui/lab' import axios from 'axios' import React, { useEffect } from 'react' import { getCities } from '../../../actions/cities' @@ -31,7 +32,7 @@ const useStyles = makeStyles((theme: Theme) => const RegionManager: React.FC = (props: any) => { const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) const [activeId, setActiveId] = React.useState<number | undefined>(undefined) - const citiesTotal = useAppSelector((state) => state.cities.total) + const [errorActive, setErrorActive] = React.useState(false) const cities = useAppSelector((state) => state.cities.cities) const [newCity, setNewCity] = React.useState<string>() const classes = useStyles() @@ -53,8 +54,8 @@ const RegionManager: React.FC = (props: any) => { setAnchorEl(null) dispatch(getCities()) }) - .catch(({ response }) => { - console.warn(response.data) + .catch((response) => { + if (response?.response?.status === 409) setErrorActive(true) }) } } @@ -98,6 +99,9 @@ const RegionManager: React.FC = (props: any) => { Ta bort </RemoveMenuItem> </Menu> + <Snackbar open={errorActive} autoHideDuration={4000} onClose={() => setErrorActive(false)}> + <Alert severity="error">{`Du kan inte ta bort regionen eftersom det finns användare eller tävlingar kopplade till den.`}</Alert> + </Snackbar> </div> ) } diff --git a/client/src/pages/admin/users/AddUser.tsx b/client/src/pages/admin/users/AddUser.tsx index 2634751d8b08f3ca027de0400dbf02f726a33161..5670c18b50db35eb2f3dbc61a4476f83b3ffd33a 100644 --- a/client/src/pages/admin/users/AddUser.tsx +++ b/client/src/pages/admin/users/AddUser.tsx @@ -212,7 +212,13 @@ const AddUser: React.FC = (props: any) => { fullWidth variant="contained" color="secondary" - disabled={!formik.isValid || !formik.values.model?.name || !formik.values.model?.city} + disabled={ + !formik.isValid || + !formik.values.model?.email || + !formik.values.model?.password || + !selectedCity?.name || + !selectedRole?.name + } > Lägg till </Button> diff --git a/client/src/pages/admin/users/UserManager.tsx b/client/src/pages/admin/users/UserManager.tsx index 20f5738604e48abe05d2ac280aff1ec56d896f6d..9713e90f3159ee1d8cc91b2cc9916438a2537ab0 100644 --- a/client/src/pages/admin/users/UserManager.tsx +++ b/client/src/pages/admin/users/UserManager.tsx @@ -47,23 +47,6 @@ const UserManager: React.FC = (props: any) => { const dispatch = useAppDispatch() const open = Boolean(anchorEl) - const id = open ? 'simple-popover' : undefined - - const handleClick = (event: React.MouseEvent<HTMLButtonElement>, user: User) => { - setAnchorEl(event.currentTarget) - setSelectedUser(user) - } - - const handleClose = () => { - setAnchorEl(null) - setSelectedUser(undefined) - console.log('close') - } - - const handleEditClose = () => { - setEditAnchorEl(null) - console.log('edit close') - } useEffect(() => { dispatch(getSearchUsers()) diff --git a/client/src/pages/login/components/CompetitionLogin.tsx b/client/src/pages/login/components/CompetitionLogin.tsx index 444634740fa361ad6deb1493c6673427f5fb8717..4e2429a3f7b1334e4d6a532c2f55bff18a170682 100644 --- a/client/src/pages/login/components/CompetitionLogin.tsx +++ b/client/src/pages/login/components/CompetitionLogin.tsx @@ -61,11 +61,11 @@ const CompetitionLogin: React.FC = () => { <Button type="submit" fullWidth variant="contained" color="secondary" disabled={!formik.isValid}> Anslut till tävling </Button> - {errors && errors.message && ( + {errors && ( <Alert severity="error"> <AlertTitle>Error</AlertTitle> - <Typography>En tävling med den koden hittades ej.</Typography> - <Typography>kontrollera koden och försök igen</Typography> + <Typography>En tävling med den koden existerar ej.</Typography> + <Typography>Dubbelkolla koden och försök igen</Typography> </Alert> )} {loading && <CenteredCircularProgress color="secondary" />} diff --git a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx index fd63a4b30069dc7866a7fc5ee729042b058fdb2f..64b4a820bec7000e9fe6f87b4c1fff5a2fd81f5f 100644 --- a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx +++ b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx @@ -1,6 +1,6 @@ import { Divider, FormControl, InputLabel, ListItem, MenuItem, Select, TextField, Typography } from '@material-ui/core' import axios from 'axios' -import React from 'react' +import React, { useState } from 'react' import { useParams } from 'react-router-dom' import { getEditorCompetition } from '../../../actions/editor' import { useAppDispatch, useAppSelector } from '../../../hooks' @@ -15,6 +15,7 @@ interface CompetitionParams { const CompetitionSettings: React.FC = () => { const { competitionId }: CompetitionParams = useParams() + const [nameErrorText, setNameErrorText] = useState<string | undefined>(undefined) const dispatch = useAppDispatch() const competition = useAppSelector((state) => state.editor.competition) const cities = useAppSelector((state) => state.cities.cities) @@ -23,9 +24,12 @@ const CompetitionSettings: React.FC = () => { await axios .put(`/api/competitions/${competitionId}`, { name: event.target.value }) .then(() => { + setNameErrorText(undefined) dispatch(getEditorCompetition(competitionId)) }) - .catch(console.log) + .catch((response) => { + if (response?.response.status === 409) setNameErrorText('Det finns redan en tävling med det namnet.') + }) } const updateCompetitionCity = async (city: City) => { @@ -52,6 +56,8 @@ const CompetitionSettings: React.FC = () => { <FirstItem> <ListItem> <TextField + error={Boolean(nameErrorText)} + helperText={nameErrorText} id="outlined-basic" label={'Tävlingsnamn'} defaultValue={competition.name} diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx index 526a1d0f5bffcb76c8309cf423ab6be0f590fb08..fb70e055dfc4858062a9123cf56dcb66fd6decdd 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react' import { getEditorCompetition } from '../../../../actions/editor' import { useAppDispatch } from '../../../../hooks' import { RichSlide } from '../../../../interfaces/ApiRichModels' -import { Center, SettingsList } from '../styled' +import { Center, SettingsItemContainer, SettingsList } from '../styled' type QuestionSettingsProps = { activeSlide: RichSlide @@ -13,7 +13,22 @@ type QuestionSettingsProps = { const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) => { const dispatch = useAppDispatch() - const maxScore = 1000 + const [timerHandle, setTimerHandle] = useState<number | undefined>(undefined) + const handleChangeQuestion = ( + updateTitle: boolean, + event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement> + ) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates question and api 500ms after last input was made + setTimerHandle(window.setTimeout(() => updateQuestion(updateTitle, event), 300)) + if (updateTitle) { + setName(event.target.value) + } else setScore(+event.target.value) + } + const maxScore = 1000000 const updateQuestion = async ( updateTitle: boolean, @@ -30,40 +45,24 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) }) .catch(console.log) } else { - if (+event.target.value > maxScore) { - setScore(maxScore) - await axios - .put( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, - { - total_score: maxScore, - } - ) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - return maxScore - } else { - setScore(+event.target.value) - await axios - .put( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, - { - total_score: event.target.value, - } - ) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } + // Sets score to event.target.value if it's between 0 and max + const score = Math.max(0, Math.min(+event.target.value, maxScore)) + setScore(score) + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, { + total_score: score, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) } } } - const [score, setScore] = useState<number | undefined>(0) + const [name, setName] = useState<string | undefined>('') useEffect(() => { + setName(activeSlide?.questions?.[0]?.name) setScore(activeSlide?.questions?.[0]?.total_score) }, [activeSlide]) @@ -77,26 +76,28 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) <ListItem divider> <TextField id="outlined-basic" - defaultValue={''} label="Frågans titel" - onChange={(event) => updateQuestion(true, event)} + onChange={(event) => handleChangeQuestion(true, event)} variant="outlined" fullWidth={true} + value={name || ''} /> </ListItem> <ListItem> <Center> - <TextField - fullWidth={true} - variant="outlined" - placeholder="Antal poäng" - helperText="Välj hur många poäng frågan ska ge för rätt svar." - label="Poäng" - type="number" - InputProps={{ inputProps: { min: 0, max: maxScore } }} - value={score || 0} - onChange={(event) => updateQuestion(false, event)} - /> + <SettingsItemContainer> + <TextField + fullWidth={true} + 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" + label="Poäng" + type="number" + InputProps={{ inputProps: { min: 0, max: maxScore } }} + value={score || ''} + onChange={(event) => handleChangeQuestion(false, event)} + /> + </SettingsItemContainer> </Center> </ListItem> </SettingsList> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx index 8fbae43816a55a2be1f1dcc838afffd9f3cf0b9e..2e4ee21619371873ea6bcc48b5d1b8ac55ad4d6f 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx @@ -56,6 +56,7 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { ) .then(() => { dispatch(getEditorCompetition(competitionId)) + removeQuestionComponent() }) .catch(console.log) } else { @@ -73,11 +74,11 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(() => { dispatch(getEditorCompetition(competitionId)) - createQuestionComponent() + removeQuestionComponent().then(() => createQuestionComponent()) }) .catch(console.log) } - } else if (selectedSlideType !== 0) { + } else if (activeSlide.questions[0].type_id === 0 && selectedSlideType !== 0) { // Change slide type from information to a question type await axios .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions`, { @@ -94,8 +95,8 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { } } - const createQuestionComponent = () => { - axios + const createQuestionComponent = async () => { + await axios .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components`, { x: 0, y: 0, @@ -111,6 +112,15 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { .catch(console.log) } + const removeQuestionComponent = async () => { + const questionComponentId = activeSlide.components.find((component) => component.type_id === 3)?.id + if (questionComponentId) { + await axios + .delete(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components/${questionComponentId}`) + .catch(console.log) + } + } + const deleteQuestionComponent = (componentId: number | undefined) => { if (componentId) { axios @@ -125,30 +135,20 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { <FormControl fullWidth variant="outlined"> <InputLabel>Sidtyp</InputLabel> <Select fullWidth={true} value={activeSlide?.questions?.[0]?.type_id || 0} label="Sidtyp"> - <MenuItem value={0}> - <Typography variant="button" onClick={() => openSlideTypeDialog(0)}> - Informationssida - </Typography> + <MenuItem value={0} button onClick={() => openSlideTypeDialog(0)}> + <Typography>Informationssida</Typography> </MenuItem> - <MenuItem value={1}> - <Typography variant="button" onClick={() => openSlideTypeDialog(1)}> - Skriftlig fråga - </Typography> + <MenuItem value={1} button onClick={() => openSlideTypeDialog(1)}> + <Typography>Skriftlig fråga</Typography> </MenuItem> - <MenuItem value={2}> - <Typography variant="button" onClick={() => openSlideTypeDialog(2)}> - Praktisk fråga - </Typography> + <MenuItem value={2} button onClick={() => openSlideTypeDialog(2)}> + <Typography>Praktisk fråga</Typography> </MenuItem> - <MenuItem value={3}> - <Typography variant="button" onClick={() => openSlideTypeDialog(3)}> - Kryssfråga - </Typography> + <MenuItem value={3} button onClick={() => openSlideTypeDialog(3)}> + <Typography>Kryssfråga</Typography> </MenuItem> - <MenuItem value={4}> - <Typography variant="button" onClick={() => openSlideTypeDialog(4)}> - Alternativfråga - </Typography> + <MenuItem value={4} button onClick={() => openSlideTypeDialog(4)}> + <Typography>Alternativfråga</Typography> </MenuItem> </Select> </FormControl> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx index 415152d3e9e9301c17a0462280a891c9ba3443e5..086a64a480c1f7d6de820d91c03d47cb327c1467 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react' import { getEditorCompetition } from '../../../../actions/editor' import { useAppDispatch } from '../../../../hooks' import { RichSlide } from '../../../../interfaces/ApiRichModels' -import { Center } from '../styled' +import { Center, SettingsItemContainer } from '../styled' type TimerProps = { activeSlide: RichSlide @@ -12,25 +12,27 @@ type TimerProps = { } const Timer = ({ activeSlide, competitionId }: TimerProps) => { - const maxTime = 1000 // ms + const maxTime = 1000000 // ms const dispatch = useAppDispatch() + const [timerHandle, setTimerHandle] = useState<number | undefined>(undefined) + const handleChangeTimer = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates slide and api 300s after last input was made + setTimerHandle(window.setTimeout(() => updateTimer(event), 300)) + setTimer(+event.target.value) + } + const updateTimer = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { /** If timer value is above the max value, set the timer value to max value to not overflow the server */ - if (+event.target.value > maxTime) { - setTimer(maxTime) - await axios - .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}`, { timer: maxTime || null }) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - return maxTime - } else { - setTimer(+event.target.value) - } + // Sets score to event.target.value if it's between 0 and max + const timerValue = Math.max(0, Math.min(+event.target.value, maxTime)) if (activeSlide) { + setTimer(timerValue) await axios - .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}`, { timer: event.target.value || null }) + .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}`, { timer: timerValue }) .then(() => { dispatch(getEditorCompetition(competitionId)) }) @@ -38,25 +40,27 @@ const Timer = ({ activeSlide, competitionId }: TimerProps) => { } } - const [timer, setTimer] = useState<number | undefined>(activeSlide?.timer) + const [timer, setTimer] = useState<number | null>(activeSlide?.timer) useEffect(() => { setTimer(activeSlide?.timer) }, [activeSlide]) return ( <ListItem> <Center> - <TextField - id="standard-number" - fullWidth={true} - variant="outlined" - placeholder="Antal sekunder" - helperText="Lämna blank för att inte använda timerfunktionen" - label="Timer" - type="number" - onChange={updateTimer} - inputProps={{ max: maxTime }} - value={timer || ''} - /> + <SettingsItemContainer> + <TextField + id="standard-number" + fullWidth={true} + variant="outlined" + placeholder="Antal sekunder" + helperText="Lämna blank för att inte använda timerfunktionen" + label="Timer" + type="number" + onChange={handleChangeTimer} + InputProps={{ inputProps: { min: 0, max: 1000000 } }} + value={timer || ''} + /> + </SettingsItemContainer> </Center> </ListItem> ) diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 31e40d51de8a4ecb45fbddf1cda9b7e96d4151f5..c214a7ce1cdf896d0219e8794f87fb79b641bb6f 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -58,6 +58,7 @@ export const Center = styled.div` text-align: center; height: 100%; width: 100%; + overflow-x: hidden; ` export const ImageTextContainer = styled.div` @@ -140,3 +141,7 @@ export const ImageNameText = styled(ListItemText)` export const QuestionComponent = styled.div` outline-style: double; ` + +export const SettingsItemContainer = styled.div` + padding: 5px; +` diff --git a/client/src/pages/presentationEditor/styled.tsx b/client/src/pages/presentationEditor/styled.tsx index 779599dab4cd748333631b734decf2051389389c..4b6e7a0cbae81dbf5c09f4c7eaf0cb02c300c854 100644 --- a/client/src/pages/presentationEditor/styled.tsx +++ b/client/src/pages/presentationEditor/styled.tsx @@ -20,11 +20,7 @@ export const ToolBarContainer = styled(Toolbar)` ` export const ViewButton = styled(Button)<ViewButtonProps>` - background: ${(props) => (props.$activeView ? '#5a0017' : undefined)}; -` - -export const ViewButtonClicked = styled(Button)` - background: #5a0017; + background: ${(props) => (!props.$activeView ? '#5a0017' : undefined)}; ` export const SlideList = styled(List)` diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index a28348a74460d45201f9c9b8825ce23daee87acb..c25b09dd29c6fe7d9930fab352d1006b0ccee7c0 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -53,6 +53,7 @@ const JudgeViewPage: React.FC = () => { const [currentSlide, setCurrentSlide] = useState<RichSlide | undefined>(undefined) const currentQuestion = currentSlide?.questions[0] const operatorActiveSlideId = useAppSelector((state) => state.presentation.activeSlideId) + const timer = useAppSelector((state) => state.presentation.timer) const operatorActiveSlideOrder = useAppSelector( (state) => state.presentation.competition.slides.find((slide) => slide.id === operatorActiveSlideId)?.order ) @@ -74,6 +75,12 @@ const JudgeViewPage: React.FC = () => { dispatch(getPresentationCompetition(competitionId.toString())) } }, [operatorActiveSlideId]) + useEffect(() => { + // Every second tic of the timer, load new answers + if (timer.value % 2 === 0 && competitionId) { + dispatch(getPresentationCompetition(competitionId.toString())) + } + }, [timer.value]) return ( <div style={{ height: '100%' }}> <JudgeAppBar position="fixed"> diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index 5bb96fcb7c2b01abb91c19e6f8c3e106161dfd22..0936abaf20535b19d2a7d40610b1d57f5f075b77 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -49,8 +49,8 @@ import { OperatorContent, OperatorFooter, OperatorHeader, + OperatorHeaderItem, OperatorInnerContent, - SlideCounter, ToolBarContainer, } from './styled' @@ -101,7 +101,7 @@ const OperatorViewPage: React.FC = () => { const [openAlert, setOpen] = React.useState(false) const [openAlertCode, setOpenCode] = React.useState(false) const [codes, setCodes] = React.useState<Code[]>([]) - const [competitionName, setCompetitionName] = React.useState<string | undefined>(undefined) + const competitionName = useAppSelector((state) => state.presentation.competition.name) //const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) @@ -122,7 +122,6 @@ const OperatorViewPage: React.FC = () => { useEffect(() => { socketConnect('Operator') socketSetSlide - handleOpenCodes() setTimeout(startCompetition, 1000) // Wait for socket to connect }, []) @@ -155,7 +154,6 @@ const OperatorViewPage: React.FC = () => { const handleOpenCodes = async () => { await getCodes() - await getCompetitionName() setOpenCode(true) } @@ -174,18 +172,6 @@ const OperatorViewPage: React.FC = () => { }) .catch(console.log) } - - const getCompetitionName = async () => { - await axios - .get(`/api/competitions/${activeId}`) - .then((response) => { - setCompetitionName(response.data.name) - }) - .catch((err) => { - console.log(err) - }) - } - const getTypeName = (code: Code) => { let typeName = '' switch (code.view_type_id) { @@ -224,14 +210,7 @@ const OperatorViewPage: React.FC = () => { return ( <OperatorContainer> - <Dialog - open={openAlertCode} - onClose={handleClose} - aria-labelledby="max-width-dialog-title" - maxWidth="xl" - fullWidth={false} - fullScreen={false} - > + <Dialog open={openAlertCode} onClose={handleClose} aria-labelledby="max-width-dialog-title" maxWidth="xl"> <Center> <DialogTitle id="max-width-dialog-title" className={classes.paper} style={{ width: '100%' }}> Koder för {competitionName} @@ -303,12 +282,14 @@ const OperatorViewPage: React.FC = () => { </Button> </DialogActions> </Dialog> - <Typography variant="h3">{presentation.competition.name}</Typography> - <SlideCounter> + <OperatorHeaderItem> + <Typography variant="h3">{presentation.competition.name}</Typography> + </OperatorHeaderItem> + <OperatorHeaderItem> <Typography variant="h3"> {activeSlideOrder !== undefined && activeSlideOrder + 1} / {presentation.competition.slides.length} </Typography> - </SlideCounter> + </OperatorHeaderItem> </OperatorHeader> <div style={{ height: 0, paddingTop: 120 }} /> <OperatorContent> @@ -364,6 +345,7 @@ const OperatorViewPage: React.FC = () => { horizontal: 'center', }} > + {(!teams || teams.length === 0) && 'Det finns inga lag i denna tävling'} <List> {teams && teams.map((team) => ( @@ -373,7 +355,11 @@ const OperatorViewPage: React.FC = () => { ))} </List> </Popover> - <Snackbar open={successMessageOpen} autoHideDuration={4000} onClose={() => setSuccessMessageOpen(false)}> + <Snackbar + open={successMessageOpen && Boolean(competitionName)} + autoHideDuration={4000} + onClose={() => setSuccessMessageOpen(false)} + > <Alert severity="success">{`Du har gått med i tävlingen "${competitionName}" som operatör`}</Alert> </Snackbar> </OperatorContainer> diff --git a/client/src/pages/views/TeamViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx index daca5c09d748436f0643607e54620c2a9d58758c..4c3266bc1f2ff642936b84e31de4ac1b98763e91 100644 --- a/client/src/pages/views/TeamViewPage.tsx +++ b/client/src/pages/views/TeamViewPage.tsx @@ -8,9 +8,9 @@ import Timer from '../views/components/Timer' import { OperatorContainer, OperatorHeader, + OperatorHeaderItem, PresentationBackground, PresentationContainer, - SlideCounter, } from './styled' const TeamViewPage: React.FC = () => { @@ -38,13 +38,13 @@ const TeamViewPage: React.FC = () => { <OperatorContainer> <OperatorHeader> <Typography variant="h1"> - <Timer></Timer> + <Timer /> </Typography> - <SlideCounter> + <OperatorHeaderItem> <Typography variant="h3"> {activeSlideOrder !== undefined && activeSlideOrder + 1} / {presentation.competition.slides.length} </Typography> - </SlideCounter> + </OperatorHeaderItem> </OperatorHeader> <PresentationBackground> <PresentationContainer> diff --git a/client/src/pages/views/ViewSelectPage.tsx b/client/src/pages/views/ViewSelectPage.tsx index 75bf84986e57ffdb552a803ea190243ad3426c4d..9bd8d2ae4e4ec01249bd25b26be1927b9b3a1efc 100644 --- a/client/src/pages/views/ViewSelectPage.tsx +++ b/client/src/pages/views/ViewSelectPage.tsx @@ -12,14 +12,14 @@ const ViewSelectPage: React.FC = () => { const dispatch = useAppDispatch() const history = useHistory() const competitionId = useAppSelector((state) => state.competitionLogin.data?.competition_id) - const errorMessage = useAppSelector((state) => state.competitionLogin.errors?.message) + const errorMessage = useAppSelector((state) => state.competitionLogin.errors) const loading = useAppSelector((state) => state.competitionLogin.loading) const { code }: ViewSelectParams = useParams() const viewType = useAppSelector((state) => state.competitionLogin.data?.view) const renderView = () => { //Renders the correct view depending on view type - if (competitionId) { + if (competitionId && !errorMessage) { switch (viewType) { case 'Team': return <Redirect to={`/view/team`} /> diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx index 29f95349a4ad436f95c8e7a5d20b9b1129b94d6e..1bab9968b5fcd8bb11d03a5b963da7d63986ace5 100644 --- a/client/src/pages/views/components/Timer.tsx +++ b/client/src/pages/views/components/Timer.tsx @@ -26,7 +26,7 @@ const Timer: React.FC = () => { const timerStartValue = slide?.timer const timer = useAppSelector((state) => state.presentation.timer) useEffect(() => { - if (!slide) return + if (!slide || !slide.timer) return dispatch(setPresentationTimer({ enabled: false, value: slide.timer })) }, [timerStartValue]) diff --git a/client/src/pages/views/styled.tsx b/client/src/pages/views/styled.tsx index 4b01d63ab8df48957ab88619424a089f96f36ebd..3fe6044591c2e06f09af37cd5484fd2594f10a82 100644 --- a/client/src/pages/views/styled.tsx +++ b/client/src/pages/views/styled.tsx @@ -21,8 +21,8 @@ export const JudgeAnswersLabel = styled(Typography)` export const ViewSelectContainer = styled.div` display: flex; justify-content: center; - margin-top: 12%; - height: 100%; + padding-top: 12%; + height: calc(100%-12%); ` export const ViewSelectButtonGroup = styled.div` @@ -60,7 +60,7 @@ export const OperatorButton = styled(Button)` margin-top: 16px; ` -export const SlideCounter = styled(Button)` +export const OperatorHeaderItem = styled(Button)` margin-left: 16px; margin-right: 16px; margin-top: 16px; diff --git a/client/src/reducers/competitionLoginReducer.ts b/client/src/reducers/competitionLoginReducer.ts index f3ca764c0a6a243301dd980830c08e3d35818aab..b95efdf53f908728b5138dd8d5df806868a3e602 100644 --- a/client/src/reducers/competitionLoginReducer.ts +++ b/client/src/reducers/competitionLoginReducer.ts @@ -7,15 +7,11 @@ interface CompetitionLoginData { team_id: number | null view: string } -/** Define a type for UI error */ -interface UIError { - message: string -} /** Define a type for the competition login state */ interface CompetitionLoginState { loading: boolean - errors: null | UIError + errors: null | string authenticated: boolean data: CompetitionLoginData | null initialized: boolean @@ -43,7 +39,7 @@ export default function (state = initialState, action: AnyAction) { case Types.SET_COMPETITION_LOGIN_ERRORS: return { ...state, - errors: action.payload as UIError, + errors: action.payload as string, loading: false, } case Types.CLEAR_COMPETITION_LOGIN_ERRORS: diff --git a/client/src/reducers/presentationReducer.ts b/client/src/reducers/presentationReducer.ts index 4df814a85a0d60a671aebd9db433fa8094526b15..cc183fadade89bd7eb504e8b501be8deb3aa3b04 100644 --- a/client/src/reducers/presentationReducer.ts +++ b/client/src/reducers/presentationReducer.ts @@ -49,12 +49,13 @@ export default function (state = initialState, action: AnyAction) { activeSlideId: action.payload as number, } case Types.SET_PRESENTATION_TIMER: + const timer = action.payload as Timer if (action.payload.value == 0) { - action.payload.enabled = false + timer.enabled = false } return { ...state, - timer: action.payload, + timer, } default: return state diff --git a/server/app/core/http_codes.py b/server/app/core/http_codes.py index f6f19ed13519be9ff5b76ca2c6c53e3cfb91c3c2..1f6f5524e54d1fed15d989ce6407a3ed1b4ddc42 100644 --- a/server/app/core/http_codes.py +++ b/server/app/core/http_codes.py @@ -8,6 +8,7 @@ BAD_REQUEST = 400 UNAUTHORIZED = 401 FORBIDDEN = 403 NOT_FOUND = 404 +CONFLICT = 409 GONE = 410 INTERNAL_SERVER_ERROR = 500 SERVICE_UNAVAILABLE = 503 diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index 71871cdaeff6f96c46c7991874cc6fa646131592..a1675b2f0211aa52ed6327a67b6c54a9ad2d70f4 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -98,7 +98,7 @@ def start_presentation(data: Dict) -> None: presentations[competition_id] = { "clients": {request.sid: {"view_type": "Operator"}}, - "slide": None, + "slide": 0, "timer": {"enabled": False, "start_value": None, "value": None}, } @@ -185,6 +185,8 @@ def join_presentation(data: Dict) -> None: logger.info(f"Client '{request.sid}' joined competition '{competition_id}'") + emit("set_slide", {"slide_order": presentations[competition_id]["slide"]}) + @protect_route(allowed_views=["Operator"]) @sio.on("set_slide") diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index d0adf5a00e9bdbd610b4892837edc345e3dc20be..9ba36dc911afb6846862f66160c88ccdf0934889 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -46,6 +46,8 @@ def db_add(item): db.session.add(item) db.session.commit() db.session.refresh(item) + except (exc.IntegrityError): + abort(codes.CONFLICT, f"Item of type {type(item)} cannot be added due to an Integrity Constraint") except (exc.SQLAlchemyError, exc.DBAPIError): db.session.rollback() # SQL errors such as item already exists @@ -64,35 +66,36 @@ def db_add(item): return item -def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, **data): +def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, **data): """ Adds a component to the slide at the specified coordinates with the provided size and data. """ - if type_id == 2: # 2 is image - item_image = get.one(Media, data["media_id"]) - filename = item_image.filename - path = os.path.join( - current_app.config["UPLOADED_PHOTOS_DEST"], - filename, - ) - with Image.open(path) as im: - h = im.height - w = im.width - - largest = max(w, h) - if largest > 600: - ratio = 600 / largest - w *= ratio - h *= ratio - if type_id == ID_TEXT_COMPONENT: item = db_add( TextComponent(slide_id, type_id, view_type_id, x, y, w, h), ) item.text = data.get("text") elif type_id == ID_IMAGE_COMPONENT: + 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"]) + filename = item_image.filename + path = os.path.join( + current_app.config["UPLOADED_PHOTOS_DEST"], + filename, + ) + + with Image.open(path) as im: + h = im.height + w = im.width + + largest = max(w, h) + if largest > 600: + ratio = 600 / largest + w *= ratio + h *= ratio + item = db_add( ImageComponent(slide_id, type_id, view_type_id, x, y, w, h), ) diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index dd8073342ecfb0460e54783e70e50c0e194d437a..07816e3e46424fc8d6b698730ce34d75341cc994 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -68,6 +68,7 @@ def component(item_component, slide_id_new, view_type_id): item_component.y, item_component.w, item_component.h, + copy=True, **data, ) diff --git a/server/app/database/controller/delete.py b/server/app/database/controller/delete.py index 65737cb69d3ace8af493d71d245805b8f4869446..51070ed9a44693d2f17acde08f0219448721f906 100644 --- a/server/app/database/controller/delete.py +++ b/server/app/database/controller/delete.py @@ -7,6 +7,7 @@ import app.database.controller as dbc from app.core import db from app.database.models import Whitelist from flask_restx import abort +from sqlalchemy.exc import IntegrityError def default(item): @@ -15,6 +16,9 @@ def default(item): try: db.session.delete(item) db.session.commit() + except IntegrityError: + db.session.rollback() + abort(codes.CONFLICT, f"Item of type {type(item)} cannot be deleted due to an Integrity Constraint") except: db.session.rollback() abort( diff --git a/server/app/database/controller/edit.py b/server/app/database/controller/edit.py index efb4909696cf2ae911a2451e1427b60b460f1153..9f54df3e088b9c13f942f898453ab54741df521c 100644 --- a/server/app/database/controller/edit.py +++ b/server/app/database/controller/edit.py @@ -2,29 +2,11 @@ This file contains functionality to get data from the database. """ +import app.core.http_codes as codes from app.core import db from app.core.parsers import sentinel - - -def switch_order(item1, item2): - """ Switches order between two slides. """ - - old_order = item1.order - new_order = item2.order - - item2.order = -1 - db.session.commit() - db.session.refresh(item2) - - item1.order = new_order - db.session.commit() - db.session.refresh(item1) - - item2.order = old_order - db.session.commit() - db.session.refresh(item2) - - return item1 +from flask_restx.errors import abort +from sqlalchemy import exc def default(item, **kwargs): @@ -49,6 +31,10 @@ def default(item, **kwargs): raise AttributeError(f"Item of type {type(item)} has no attribute '{key}'") if value is not sentinel: setattr(item, key, value) - db.session.commit() + try: + db.session.commit() + except exc.IntegrityError: + abort(codes.CONFLICT, f"Item of type {type(item)} cannot be edited due to an Integrity Constraint") + db.session.refresh(item) return item diff --git a/server/app/database/models.py b/server/app/database/models.py index 97eb6097c2403cf4d32d01106e2ae906d9d788d5..bc13c094cc3b6a75e0935005a81de991068d6856 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 ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT +from app.database.types import (ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, + ID_TEXT_COMPONENT) from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property STRING_SIZE = 254 @@ -146,7 +147,7 @@ class Slide(db.Model): order = db.Column(db.Integer, nullable=False) title = db.Column(db.String(STRING_SIZE), nullable=False, default="") body = db.Column(db.Text, nullable=False, default="") - timer = db.Column(db.Integer, nullable=False, default=0) + timer = db.Column(db.Integer, nullable=True) settings = db.Column(db.Text, nullable=False, default="{}") competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=False) @@ -164,7 +165,7 @@ class Slide(db.Model): class Question(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), nullable=False) - total_score = db.Column(db.Integer, nullable=False, default=1) + total_score = db.Column(db.Integer, nullable=True, default=None) type_id = db.Column(db.Integer, db.ForeignKey("question_type.id"), nullable=False) slide_id = db.Column(db.Integer, db.ForeignKey("slide.id"), nullable=False) correcting_instructions = db.Column(db.Text, nullable=True, default=None)