From c29e4a116d56e239af5ef3237e886d0e01b4691d Mon Sep 17 00:00:00 2001 From: Albin Henriksson <albhe428@student.liu.se> Date: Fri, 2 Apr 2021 15:46:52 +0000 Subject: [PATCH] Resolve "Implement create competition" --- client/src/App.test.tsx | 12 +- client/src/App.tsx | 7 +- client/src/__mocks__/axios.js | 3 + client/src/actions/cities.ts | 15 ++ client/src/actions/competitions.ts | 39 ++++ client/src/actions/types.ts | 5 + client/src/actions/user.ts | 23 +- client/src/hooks.ts | 5 + client/src/interfaces/AdminLoginData.ts | 4 + client/src/interfaces/City.ts | 4 + client/src/interfaces/Competition.ts | 7 + .../src/interfaces/CompetitionFilterParams.ts | 8 + client/src/interfaces/models.ts | 6 + client/src/pages/admin/AdminPage.tsx | 31 +-- .../pages/admin/components/AddCompetition.tsx | 182 +++++++++++++++ .../components/CompetitionManager.test.tsx | 46 +++- .../admin/components/CompetitionManager.tsx | 213 ++++++++---------- client/src/pages/admin/components/styled.tsx | 23 +- client/src/pages/admin/styled.tsx | 13 ++ .../src/pages/login/components/AdminLogin.tsx | 31 +-- .../login/components/CompetitionLogin.tsx | 1 - client/src/reducers/allReducers.ts | 4 + client/src/reducers/citiesReducer.ts | 14 ++ client/src/reducers/competitionsReducer.ts | 45 ++++ client/src/reducers/uiReducer.ts | 17 +- client/src/reducers/userReducer.ts | 24 +- client/src/store.ts | 7 +- client/src/utils/SecureRoute.tsx | 22 +- client/src/utils/checkAuthentication.ts | 17 +- 29 files changed, 627 insertions(+), 201 deletions(-) create mode 100644 client/src/__mocks__/axios.js create mode 100644 client/src/actions/cities.ts create mode 100644 client/src/actions/competitions.ts create mode 100644 client/src/hooks.ts create mode 100644 client/src/interfaces/AdminLoginData.ts create mode 100644 client/src/interfaces/City.ts create mode 100644 client/src/interfaces/Competition.ts create mode 100644 client/src/interfaces/CompetitionFilterParams.ts create mode 100644 client/src/pages/admin/components/AddCompetition.tsx create mode 100644 client/src/pages/admin/styled.tsx create mode 100644 client/src/reducers/citiesReducer.ts create mode 100644 client/src/reducers/competitionsReducer.ts diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index f27dce75..2eca756c 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -1,13 +1,13 @@ +import { render } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import App from './App' import store from './store' test('renders app', () => { - ;<Provider store={store}> - render( - <App />) - </Provider> - // const linkElement = screen.getByText(/learn react/i) - // expect(linkElement).toBeInTheDocument() + render( + <Provider store={store}> + <App /> + </Provider> + ) }) diff --git a/client/src/App.tsx b/client/src/App.tsx index 61b64bee..fab142f7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,9 @@ -import { createMuiTheme, MuiThemeProvider, StylesProvider } from '@material-ui/core' +import { + MuiThemeProvider, + StylesProvider, + unstable_createMuiStrictModeTheme as createMuiTheme, +} from '@material-ui/core' +// unstable version of createMuiTheme is required for React.StrictMode currently import React from 'react' import { ThemeProvider } from 'styled-components' import Main from './Main' diff --git a/client/src/__mocks__/axios.js b/client/src/__mocks__/axios.js new file mode 100644 index 00000000..c3547a13 --- /dev/null +++ b/client/src/__mocks__/axios.js @@ -0,0 +1,3 @@ +export default { + get: jest.fn().mockImplementation(), +} diff --git a/client/src/actions/cities.ts b/client/src/actions/cities.ts new file mode 100644 index 00000000..381d0a04 --- /dev/null +++ b/client/src/actions/cities.ts @@ -0,0 +1,15 @@ +import axios from 'axios' +import { AppDispatch } from './../store' +import Types from './types' + +export const getCities = () => async (dispatch: AppDispatch) => { + await axios + .get('/misc/cities') + .then((res) => { + dispatch({ + type: Types.SET_CITIES, + payload: res.data, + }) + }) + .catch((err) => console.log(err)) +} diff --git a/client/src/actions/competitions.ts b/client/src/actions/competitions.ts new file mode 100644 index 00000000..35f12d31 --- /dev/null +++ b/client/src/actions/competitions.ts @@ -0,0 +1,39 @@ +import axios from 'axios' +import { CompetitionFilterParams } from '../interfaces/CompetitionFilterParams' +import { AppDispatch, RootState } from './../store' +import Types from './types' + +export const getCompetitions = () => async (dispatch: AppDispatch, getState: () => RootState) => { + const currentParams: CompetitionFilterParams = getState().competitions.filterParams + // Send params in snake-case for api + const params = { + page_size: currentParams.pageSize, + style_id: currentParams.styleId, + city_id: currentParams.cityId, + name: currentParams.name, + page: currentParams.page, + year: currentParams.year, + } + await axios + .get('/competitions/search', { params }) + .then((res) => { + dispatch({ + type: Types.SET_COMPETITIONS, + payload: res.data.competitions, + }) + dispatch({ + type: Types.SET_COMPETITIONS_TOTAL, + payload: res.data.total, + }) + dispatch({ + type: Types.SET_COMPETITIONS_COUNT, + payload: res.data.count, + }) + }) + .catch((err) => { + console.log(err) + }) +} +export const setFilterParams = (params: CompetitionFilterParams) => (dispatch: AppDispatch) => { + dispatch({ type: Types.SET_COMPETITIONS_FILTER_PARAMS, payload: params }) +} diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index d0fb0d7c..d265a692 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -6,6 +6,11 @@ export default { CLEAR_ERRORS: 'SET_ERRORS', SET_UNAUTHENTICATED: 'SET_UNAUTHENTICATED', SET_AUTHENTICATED: 'SET_AUTHENTICATED', + SET_COMPETITIONS: 'SET_COMPETITIONS', + SET_COMPETITIONS_FILTER_PARAMS: 'SET_COMPETITIONS_FILTER_PARAMS', + SET_COMPETITIONS_TOTAL: 'SET_COMPETITIONS_TOTAL', + SET_COMPETITIONS_COUNT: 'SET_COMPETITIONS_COUNT', + SET_CITIES: 'SET_CITIES', AXIOS_GET: 'AXIOS_GET', AXIOS_GET_SUCCESS: 'AXIOS_GET_SUCCESS', AXIOS_GET_ERROR: 'AXIOS_GET_ERROR', diff --git a/client/src/actions/user.ts b/client/src/actions/user.ts index 30a9973a..104722eb 100644 --- a/client/src/actions/user.ts +++ b/client/src/actions/user.ts @@ -1,15 +1,18 @@ import axios from 'axios' +import { History } from 'history' +import { AppDispatch } from '../store' +import { AdminLoginData } from './../interfaces/AdminLoginData' import Types from './types' -export const loginUser = (userData: any, history: any) => (dispatch: any) => { +export const loginUser = (userData: AdminLoginData, history: History) => async (dispatch: AppDispatch) => { dispatch({ type: Types.LOADING_UI }) - axios + await axios .post('/auth/login', userData) .then((res) => { const token = `Bearer ${res.data.access_token}` localStorage.setItem('token', token) //setting token to local storage axios.defaults.headers.common['Authorization'] = token //setting authorize token to header in axios - dispatch(getUserData()) + getUserData()(dispatch) dispatch({ type: Types.CLEAR_ERRORS }) // no error history.push('/admin') //redirecting to admin page after login success }) @@ -22,14 +25,20 @@ export const loginUser = (userData: any, history: any) => (dispatch: any) => { }) } -export const getUserData = () => (dispatch: any) => { +export const getUserData = () => async (dispatch: AppDispatch) => { dispatch({ type: Types.LOADING_USER }) - axios + await axios .get('/users') .then((res) => { dispatch({ type: Types.SET_USER, - payload: res.data, + payload: { + id: res.data.id, + name: res.data.name, + email: res.data.email, + roleId: res.data.role_id, + cityId: res.data.city_id, + }, }) }) .catch((err) => { @@ -37,7 +46,7 @@ export const getUserData = () => (dispatch: any) => { }) } -export const logoutUser = () => (dispatch: any) => { +export const logoutUser = () => (dispatch: AppDispatch) => { localStorage.removeItem('token') delete axios.defaults.headers.common['Authorization'] dispatch({ diff --git a/client/src/hooks.ts b/client/src/hooks.ts new file mode 100644 index 00000000..597f2813 --- /dev/null +++ b/client/src/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' +import type { AppDispatch, RootState } from './store' + +export const useAppDispatch = () => useDispatch<AppDispatch>() +export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector diff --git a/client/src/interfaces/AdminLoginData.ts b/client/src/interfaces/AdminLoginData.ts new file mode 100644 index 00000000..70f61036 --- /dev/null +++ b/client/src/interfaces/AdminLoginData.ts @@ -0,0 +1,4 @@ +export interface AdminLoginData { + email: string + password: string +} diff --git a/client/src/interfaces/City.ts b/client/src/interfaces/City.ts new file mode 100644 index 00000000..e5190b6b --- /dev/null +++ b/client/src/interfaces/City.ts @@ -0,0 +1,4 @@ +export interface City { + id: number + name: string +} diff --git a/client/src/interfaces/Competition.ts b/client/src/interfaces/Competition.ts new file mode 100644 index 00000000..433ae5a3 --- /dev/null +++ b/client/src/interfaces/Competition.ts @@ -0,0 +1,7 @@ +export interface Competition { + name: string + city_id: number + style_id: number + year: number + id: number +} diff --git a/client/src/interfaces/CompetitionFilterParams.ts b/client/src/interfaces/CompetitionFilterParams.ts new file mode 100644 index 00000000..83946052 --- /dev/null +++ b/client/src/interfaces/CompetitionFilterParams.ts @@ -0,0 +1,8 @@ +export interface CompetitionFilterParams { + name?: string + year?: number + cityId?: number + styleId?: number + page: number + pageSize: number +} diff --git a/client/src/interfaces/models.ts b/client/src/interfaces/models.ts index f2bc0b12..91920bd6 100644 --- a/client/src/interfaces/models.ts +++ b/client/src/interfaces/models.ts @@ -3,6 +3,12 @@ export interface AccountLoginModel { password: string } +export interface AddCompetitionModel { + name: string + city: string + year: number +} + export interface CompetitionLoginModel { code: string } diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index 5a524470..3d2b94b8 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -3,7 +3,6 @@ import { Button, CssBaseline, Divider, - Drawer, List, ListItem, ListItemIcon, @@ -15,13 +14,14 @@ import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import DashboardIcon from '@material-ui/icons/Dashboard' import MailIcon from '@material-ui/icons/Mail' import React from 'react' -import { connect } from 'react-redux' import { Link, Route, Switch, useRouteMatch } from 'react-router-dom' import { logoutUser } from '../../actions/user' +import { useAppDispatch } from '../../hooks' import CompetitionManager from './components/CompetitionManager' import Regions from './components/Regions' +import { LeftDrawer } from './styled' -const drawerWidth = 240 +const drawerWidth = 250 const menuItems = ['Startsida', 'Regioner', 'Användare', 'Tävlingshanterare'] const useStyles = makeStyles((theme: Theme) => @@ -30,14 +30,9 @@ const useStyles = makeStyles((theme: Theme) => display: 'flex', }, appBar: { - width: `calc(100% - ${drawerWidth}px)`, + width: '100%', marginLeft: drawerWidth, }, - drawer: { - width: drawerWidth, - flexShrink: 0, - marginRight: drawerWidth, - }, drawerPaper: { width: drawerWidth, }, @@ -51,13 +46,14 @@ const useStyles = makeStyles((theme: Theme) => }) ) -const AdminView: React.FC = (props: any) => { +const AdminView: React.FC = () => { const classes = useStyles() const [openIndex, setOpenIndex] = React.useState(0) const { path, url } = useRouteMatch() const handleLogout = () => { - props.logoutUser() + dispatch(logoutUser()) } + const dispatch = useAppDispatch() return ( <div className={classes.root}> <CssBaseline /> @@ -68,12 +64,12 @@ const AdminView: React.FC = (props: any) => { </Typography> </Toolbar> </AppBar> - <Drawer - className={(classes.drawer, 'background')} - variant="permanent" + <LeftDrawer + width={drawerWidth} classes={{ paper: classes.drawerPaper, }} + variant="permanent" anchor="left" > <div> @@ -103,7 +99,7 @@ const AdminView: React.FC = (props: any) => { </ListItem> </List> </div> - </Drawer> + </LeftDrawer> <main className={classes.content}> <div className={classes.toolbar} /> <Switch> @@ -128,7 +124,4 @@ const AdminView: React.FC = (props: any) => { </div> ) } -const mapDispatchToProps = { - logoutUser, -} -export default connect(null, mapDispatchToProps)(AdminView) +export default AdminView diff --git a/client/src/pages/admin/components/AddCompetition.tsx b/client/src/pages/admin/components/AddCompetition.tsx new file mode 100644 index 00000000..3bb54db0 --- /dev/null +++ b/client/src/pages/admin/components/AddCompetition.tsx @@ -0,0 +1,182 @@ +import { Button, FormControl, InputLabel, MenuItem, Popover, TextField } from '@material-ui/core' +import { Alert, AlertTitle } from '@material-ui/lab' +import axios from 'axios' +import { Formik, FormikHelpers } from 'formik' +import React from 'react' +import * as Yup from 'yup' +import { getCompetitions } from '../../../actions/competitions' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { City } from '../../../interfaces/City' +import { AddCompetitionModel } from '../../../interfaces/models' +import { AddCompetitionButton, AddCompetitionContent, AddCompetitionForm } from './styled' + +interface ServerResponse { + code: number + message: string +} + +interface AddCompetitionFormModel { + model: AddCompetitionModel + error?: string +} +const noCitySelected = 'Välj stad' + +const competitionSchema: Yup.SchemaOf<AddCompetitionFormModel> = Yup.object({ + model: Yup.object() + .shape({ + name: Yup.string().required('Namn krävs'), + city: Yup.string().required('Stad krävs').notOneOf([noCitySelected], 'Välj en stad'), + year: Yup.number() + .integer('År måste vara ett heltal') + .required('År krävs') + .moreThan(1999, 'År måste vara minst 2000'), + }) + .required(), + error: Yup.string().optional(), +}) + +const AddCompetition: React.FC = (props: any) => { + const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) + const [selectedCity, setSelectedCity] = React.useState<City | undefined>() + const cities = useAppSelector((state) => state.cities) + const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + const dispatch = useAppDispatch() + const id = open ? 'simple-popover' : undefined + const currentYear = new Date().getFullYear() + const handleCompetitionSubmit = async ( + values: AddCompetitionFormModel, + actions: FormikHelpers<AddCompetitionFormModel> + ) => { + const params = { + name: values.model.name, + year: values.model.year, + city_id: selectedCity?.id as number, + style_id: 1, + } + await axios + .post<ServerResponse>('/competitions', params) + .then(() => { + actions.resetForm() + setAnchorEl(null) + dispatch(getCompetitions()) + setSelectedCity(undefined) + }) + .catch(({ response }) => { + console.warn(response.data) + if (response.data && response.data.message) + actions.setFieldError('error', response.data && response.data.message) + else actions.setFieldError('error', 'Something went wrong, please try again') + }) + .finally(() => { + actions.setSubmitting(false) + }) + } + + const competitionInitialValues: AddCompetitionFormModel = { + model: { name: '', city: noCitySelected, year: currentYear }, + } + return ( + <div> + <AddCompetitionButton color="secondary" variant="contained" onClick={handleClick}> + Ny Tävling + </AddCompetitionButton> + <Popover + id={id} + open={open} + anchorEl={anchorEl} + onClose={handleClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + <AddCompetitionContent> + <Formik + initialValues={competitionInitialValues} + validationSchema={competitionSchema} + onSubmit={handleCompetitionSubmit} + > + {(formik) => ( + <AddCompetitionForm onSubmit={formik.handleSubmit}> + <TextField + label="Namn" + name="model.name" + helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} + error={Boolean(formik.touched.model?.name && formik.errors.model?.name)} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + margin="normal" + /> + <FormControl> + <InputLabel shrink id="demo-customized-select-native"> + Region + </InputLabel> + <TextField + select + name="model.city" + id="standard-select-currency" + value={selectedCity ? selectedCity.name : noCitySelected} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + error={Boolean(formik.errors.model?.city && formik.touched.model?.city)} + helperText={formik.touched.model?.city && formik.errors.model?.city} + margin="normal" + > + <MenuItem value={noCitySelected} onClick={() => setSelectedCity(undefined)}> + {noCitySelected} + </MenuItem> + {cities && + cities.map((city) => ( + <MenuItem key={city.name} value={city.name} onClick={() => setSelectedCity(city)}> + {city.name} + </MenuItem> + ))} + </TextField> + </FormControl> + <TextField + label="År" + name="model.year" + type="number" + defaultValue={formik.initialValues.model.year} + helperText={formik.touched.model?.year ? formik.errors.model?.year : ''} + error={Boolean(formik.touched.model?.year && formik.errors.model?.year)} + onChange={formik.handleChange} + onBlur={formik.handleBlur} + margin="normal" + /> + <Button + type="submit" + fullWidth + variant="contained" + color="secondary" + disabled={!formik.isValid || !formik.values.model?.name || !formik.values.model?.city} + > + Skapa + </Button> + {formik.errors.error && ( + <Alert severity="error"> + <AlertTitle>Error</AlertTitle> + {formik.errors.error} + </Alert> + )} + </AddCompetitionForm> + )} + </Formik> + </AddCompetitionContent> + </Popover> + </div> + ) +} + +export default AddCompetition diff --git a/client/src/pages/admin/components/CompetitionManager.test.tsx b/client/src/pages/admin/components/CompetitionManager.test.tsx index 2a92138f..3fb03b69 100644 --- a/client/src/pages/admin/components/CompetitionManager.test.tsx +++ b/client/src/pages/admin/components/CompetitionManager.test.tsx @@ -1,12 +1,56 @@ import { render } from '@testing-library/react' +import mockedAxios from 'axios' import React from 'react' +import { Provider } from 'react-redux' import { BrowserRouter } from 'react-router-dom' +import store from '../../../store' import CompetitionManager from './CompetitionManager' it('renders competition manager', () => { + const cityRes: any = { + data: [ + { + id: 1, + name: 'Link\u00f6ping', + }, + { + id: 2, + name: 'Stockholm', + }, + ], + } + const compRes: any = { + data: { + competitions: [ + { + id: 21, + name: 'ggff', + year: 2021, + style_id: 1, + city_id: 1, + }, + { + id: 22, + name: 'sssss', + year: 2021, + style_id: 1, + city_id: 1, + }, + ], + count: 2, + total: 3, + }, + } + + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + if (path === '/competitions/search') return Promise.resolve(compRes) + else return Promise.resolve(cityRes) + }) render( <BrowserRouter> - <CompetitionManager /> + <Provider store={store}> + <CompetitionManager /> + </Provider> </BrowserRouter> ) }) diff --git a/client/src/pages/admin/components/CompetitionManager.tsx b/client/src/pages/admin/components/CompetitionManager.tsx index a91b6636..c6803b03 100644 --- a/client/src/pages/admin/components/CompetitionManager.tsx +++ b/client/src/pages/admin/components/CompetitionManager.tsx @@ -1,11 +1,10 @@ -import { Button, Menu } from '@material-ui/core' +import { Button, Menu, TablePagination, TextField, Typography } from '@material-ui/core' import FormControl from '@material-ui/core/FormControl' -import InputBase from '@material-ui/core/InputBase' import InputLabel from '@material-ui/core/InputLabel' import MenuItem from '@material-ui/core/MenuItem' import Paper from '@material-ui/core/Paper' import Select from '@material-ui/core/Select' -import { createStyles, makeStyles, Theme, withStyles } from '@material-ui/core/styles' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import Table from '@material-ui/core/Table' import TableBody from '@material-ui/core/TableBody' import TableCell from '@material-ui/core/TableCell' @@ -13,66 +12,20 @@ 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 React from 'react' +import axios from 'axios' +import React, { useEffect } from 'react' import { Link } from 'react-router-dom' -import { NewCompetitionButton, RemoveCompetition, TopBar } from './styled' - -const BootstrapInput = withStyles((theme: Theme) => - createStyles({ - root: { - 'label + &': { - marginTop: theme.spacing(3), - }, - }, - input: { - borderRadius: 4, - position: 'relative', - backgroundColor: theme.palette.background.paper, - border: '1px solid #ced4da', - fontSize: 16, - padding: '10px 26px 10px 12px', - transition: theme.transitions.create(['border-color', 'box-shadow']), - '&:focus': { - borderRadius: 4, - borderColor: '#80bdff', - boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)', - }, - }, - }) -)(InputBase) - -function createCompetition(name: string, region: string, year: number, id: number) { - return { name, region, year, id } -} - -const competitions = [ - createCompetition('Tävling 1', 'Stockholm', 2021, 1), - createCompetition('Tävling 2', 'Stockholm', 2020, 2), - createCompetition('Tävling 3', 'Sala', 2020, 3), - createCompetition('Tävling 4', 'Sundsvall', 2020, 4), - createCompetition('Tävling 5', 'Linköping', 2020, 5), - createCompetition('Tävling 6', 'Linköping', 2020, 6), - createCompetition('Tävling 7', 'Sala', 2019, 7), - createCompetition('Tävling 8', 'Stockholm', 2019, 8), - createCompetition('Tävling 9', 'Stockholm', 2019, 9), - createCompetition('Tävling 10', 'Lidköping', 2019, 10), - createCompetition('Tävling 11', 'Stockholm', 2019, 11), - createCompetition('Tävling 12', 'Sala', 2018, 12), - createCompetition('Tävling 13', 'Tornby', 2018, 13), -] - -const regions = competitions - .map((competition) => competition.region) - .filter((competition, index, self) => self.indexOf(competition) === index) - -const years = competitions - .map((competition) => competition.year) - .filter((competition, index, self) => self.indexOf(competition) === index) +import { getCities } from '../../../actions/cities' +import { getCompetitions, setFilterParams } from '../../../actions/competitions' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { CompetitionFilterParams } from '../../../interfaces/CompetitionFilterParams' +import AddCompetition from './AddCompetition' +import { FilterContainer, RemoveCompetition, TopBar, YearFilterTextField } from './styled' const useStyles = makeStyles((theme: Theme) => createStyles({ table: { - width: 1500, // TODO: Shrink table when smaller screen + width: '100%', }, margin: { margin: theme.spacing(1), @@ -80,37 +33,71 @@ const useStyles = makeStyles((theme: Theme) => }) ) -const CompetitionManager: React.FC = () => { +const CompetitionManager: React.FC = (props: any) => { const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) + const [activeId, setActiveId] = React.useState<number | undefined>(undefined) + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + const competitions = useAppSelector((state) => state.competitions.competitions) + const filterParams = useAppSelector((state) => state.competitions.filterParams) + const competitionTotal = useAppSelector((state) => state.competitions.total) + const cities = useAppSelector((state) => state.cities) const classes = useStyles() - const yearInitialValue = 0 - const regionInitialValue = '' const noFilterText = 'Alla' - const [searchInput, setSearchInput] = React.useState('') - const [year, setYear] = React.useState(yearInitialValue) - const [region, setRegion] = React.useState(regionInitialValue) - const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + const dispatch = useAppDispatch() + const handleClick = (event: React.MouseEvent<HTMLButtonElement>, id: number) => { setAnchorEl(event.currentTarget) + setActiveId(id) } const handleClose = () => { setAnchorEl(null) + setActiveId(undefined) } + useEffect(() => { + dispatch(getCities()) + dispatch(getCompetitions()) + }, []) + const onSearchChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { - setSearchInput(event.target.value) + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates filter and api 100ms after last input was made + setTimerHandle(window.setTimeout(() => dispatch(getCompetitions()), 100)) + dispatch(setFilterParams({ ...filterParams, name: event.target.value })) + } + + const handleDeleteCompetition = async () => { + if (activeId) { + await axios + .delete(`/competitions/${activeId}`) + .then(() => { + setAnchorEl(null) + dispatch(getCompetitions()) + }) + .catch(({ response }) => { + console.warn(response.data) + }) + } + } + + const handleFilterChange = (newParams: CompetitionFilterParams) => { + dispatch(setFilterParams(newParams)) + dispatch(getCompetitions()) } return ( <div> <TopBar> - <div> - <FormControl className={classes.margin}> - <InputLabel shrink id="demo-customized-textbox"> - Sök - </InputLabel> - <BootstrapInput id="demo-customized-textbox" onChange={onSearchChange} /> - </FormControl> + <FilterContainer> + <TextField + className={classes.margin} + value={filterParams.name || ''} + onChange={onSearchChange} + label="Sök" + ></TextField> <FormControl className={classes.margin}> <InputLabel shrink id="demo-customized-select-native"> Region @@ -118,42 +105,33 @@ const CompetitionManager: React.FC = () => { <Select labelId="demo-customized-select-label" id="demo-customized-select" - value={region === regionInitialValue ? noFilterText : region} - input={<BootstrapInput />} - > - <MenuItem value={noFilterText} onClick={() => setRegion(regionInitialValue)}> - {noFilterText} - </MenuItem> - {regions.map((text) => ( - <MenuItem key={text} value={text} onClick={() => setRegion(text)}> - {text} - </MenuItem> - ))} - </Select> - </FormControl> - <FormControl className={classes.margin}> - <InputLabel shrink id="demo-customized-select-label"> - År - </InputLabel> - <Select - id="demo-customized-select" - value={year === yearInitialValue ? noFilterText : year} - input={<BootstrapInput />} + value={filterParams.cityId ? cities.find((city) => filterParams.cityId === city.id)?.name : noFilterText} > - <MenuItem value={noFilterText} onClick={() => setYear(yearInitialValue)}> + <MenuItem value={noFilterText} onClick={() => handleFilterChange({ ...filterParams, cityId: undefined })}> {noFilterText} </MenuItem> - {years.map((year) => ( - <MenuItem key={year} value={year} onClick={() => setYear(year)}> - {year} - </MenuItem> - ))} + {cities && + cities.map((city) => ( + <MenuItem + key={city.name} + value={city.name} + onClick={() => handleFilterChange({ ...filterParams, cityId: city.id })} + > + {city.name} + </MenuItem> + ))} </Select> </FormControl> - </div> - <NewCompetitionButton color="secondary" variant="contained"> - Ny Tävling - </NewCompetitionButton> + <YearFilterTextField + label="År" + name="model.year" + type="number" + value={filterParams.year || new Date().getFullYear()} + onChange={(event) => handleFilterChange({ ...filterParams, year: +event.target.value })} + margin="normal" + /> + </FilterContainer> + <AddCompetition /> </TopBar> <TableContainer component={Paper}> <Table className={classes.table} aria-label="simple table"> @@ -166,24 +144,18 @@ const CompetitionManager: React.FC = () => { </TableRow> </TableHead> <TableBody> - {competitions - .filter((row) => { - const nameOkay = row.name.match(RegExp(searchInput, 'i')) //Makes sure name matches search input case insensitively - const yearOkay = year == yearInitialValue || row.year == year - const regionOkay = region == regionInitialValue || row.region == region - return yearOkay && regionOkay && nameOkay - }) - .map((row) => ( + {competitions && + competitions.map((row) => ( <TableRow key={row.name}> <TableCell scope="row"> <Button color="primary" component={Link} to={`/editor/competition-id=${row.id}`}> {row.name} </Button> </TableCell> - <TableCell align="right">{row.region}</TableCell> + <TableCell align="right">{cities.find((city) => city.id === row.city_id)?.name || ''}</TableCell> <TableCell align="right">{row.year}</TableCell> <TableCell align="right"> - <Button onClick={handleClick}> + <Button onClick={(event) => handleClick(event, row.id)}> <MoreHorizIcon /> </Button> </TableCell> @@ -191,11 +163,22 @@ const CompetitionManager: React.FC = () => { ))} </TableBody> </Table> + {(!competitions || competitions.length === 0) && ( + <Typography>Inga tävlingar hittades med nuvarande filter</Typography> + )} </TableContainer> + <TablePagination + component="div" + rowsPerPageOptions={[]} + rowsPerPage={filterParams.pageSize} + count={competitionTotal} + page={filterParams.page} + onChangePage={(event, newPage) => handleFilterChange({ ...filterParams, page: newPage })} + /> <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> <MenuItem onClick={handleClose}>Starta</MenuItem> <MenuItem onClick={handleClose}>Duplicera</MenuItem> - <RemoveCompetition onClick={handleClose}>Ta bort</RemoveCompetition> + <RemoveCompetition onClick={handleDeleteCompetition}>Ta bort</RemoveCompetition> </Menu> </div> ) diff --git a/client/src/pages/admin/components/styled.tsx b/client/src/pages/admin/components/styled.tsx index 7a7f75ed..69040178 100644 --- a/client/src/pages/admin/components/styled.tsx +++ b/client/src/pages/admin/components/styled.tsx @@ -1,4 +1,4 @@ -import { Button, MenuItem } from '@material-ui/core' +import { Button, MenuItem, TextField } from '@material-ui/core' import styled from 'styled-components' export const TopBar = styled.div` @@ -7,12 +7,29 @@ export const TopBar = styled.div` align-items: flex-end; ` -export const NewCompetitionButton = styled(Button)` +export const AddCompetitionButton = styled(Button)` margin-bottom: 8px; ` +export const AddCompetitionForm = styled.form` + display: flex; + flex-direction: column; +` + +export const AddCompetitionContent = styled.div` + padding: 15px; +` + export const RemoveCompetition = styled(MenuItem)` - color:red; + color: red; ` +export const YearFilterTextField = styled(TextField)` + width: 70px; +` +export const FilterContainer = styled.div` + display: flex; + align-items: flex-end; + margin: left 10px; +` diff --git a/client/src/pages/admin/styled.tsx b/client/src/pages/admin/styled.tsx new file mode 100644 index 00000000..e4687523 --- /dev/null +++ b/client/src/pages/admin/styled.tsx @@ -0,0 +1,13 @@ +import { Drawer, DrawerProps } from '@material-ui/core' +import React from 'react' +import styled from 'styled-components' + +interface leftDrawerProps extends DrawerProps { + width: number +} +export const LeftDrawer = styled((props: leftDrawerProps) => <Drawer {...props} />)` + width: ${(props) => (props.width ? props.width : '500px')}; + flex-shrink: 0; + margin-right: ${(props) => (props.width ? props.width : '500px')}; + z-index: 1; +` diff --git a/client/src/pages/login/components/AdminLogin.tsx b/client/src/pages/login/components/AdminLogin.tsx index 531ae22e..cdff4d3f 100644 --- a/client/src/pages/login/components/AdminLogin.tsx +++ b/client/src/pages/login/components/AdminLogin.tsx @@ -2,10 +2,10 @@ import { Button, TextField } from '@material-ui/core' import { Alert, AlertTitle } from '@material-ui/lab' import { Formik, FormikHelpers } from 'formik' import React, { useEffect, useState } from 'react' -import { connect } from 'react-redux' import { useHistory } from 'react-router-dom' import * as Yup from 'yup' import { loginUser } from '../../../actions/user' +import { useAppDispatch, useAppSelector } from '../../../hooks' import { AccountLoginModel } from '../../../interfaces/models' import { CenteredCircularProgress, LoginForm } from './styled' @@ -14,11 +14,6 @@ interface AccountLoginFormModel { error?: string } -interface ServerResponse { - code: number - message: string -} - interface formError { message: string } @@ -33,17 +28,20 @@ const accountSchema: Yup.SchemaOf<AccountLoginFormModel> = Yup.object({ error: Yup.string().optional(), }) -const AdminLogin: React.FC = (props: any) => { +const AdminLogin: React.FC = () => { const [errors, setErrors] = useState({} as formError) const [loading, setLoading] = useState(false) + const dispatch = useAppDispatch() + const UIErrors = useAppSelector((state) => state.UI.errors) + const UILoading = useAppSelector((state) => state.UI.loading) useEffect(() => { - if (props.UI.errors) { - setErrors(props.UI.errors) + if (UIErrors) { + setErrors(UIErrors) } - setLoading(props.UI.loading) - }, [props.UI]) + setLoading(UILoading) + }, [UIErrors, UILoading]) const handleAccountSubmit = (values: AccountLoginFormModel, actions: FormikHelpers<AccountLoginFormModel>) => { - props.loginUser(values.model, history) + dispatch(loginUser(values.model, history)) } const history = useHistory() @@ -94,11 +92,4 @@ const AdminLogin: React.FC = (props: any) => { </Formik> ) } -const mapStateToProps = (state: any) => ({ - user: state.user, - UI: state.UI, -}) -const mapDispatchToProps = { - loginUser, -} -export default connect(mapStateToProps, mapDispatchToProps)(AdminLogin) +export default AdminLogin diff --git a/client/src/pages/login/components/CompetitionLogin.tsx b/client/src/pages/login/components/CompetitionLogin.tsx index 42b9af4e..9ebf658f 100644 --- a/client/src/pages/login/components/CompetitionLogin.tsx +++ b/client/src/pages/login/components/CompetitionLogin.tsx @@ -30,7 +30,6 @@ const handleCompetitionSubmit = async ( values: CompetitionLoginFormModel, actions: FormikHelpers<CompetitionLoginFormModel> ) => { - console.log(values.model) await axios .post<ServerResponse>(`users/login`, { code: values.model.code }) .then(() => { diff --git a/client/src/reducers/allReducers.ts b/client/src/reducers/allReducers.ts index 98260e16..b12b5346 100644 --- a/client/src/reducers/allReducers.ts +++ b/client/src/reducers/allReducers.ts @@ -1,6 +1,8 @@ // Combines all the reducers so that we only have to pass "one" reducer to the store in src/index.tsx import { combineReducers } from 'redux' +import citiesReducer from './citiesReducer' +import competitionsReducer from './competitionsReducer' import uiReducer from './uiReducer' import userReducer from './userReducer' @@ -8,5 +10,7 @@ const allReducers = combineReducers({ // name: state user: userReducer, UI: uiReducer, + competitions: competitionsReducer, + cities: citiesReducer, }) export default allReducers diff --git a/client/src/reducers/citiesReducer.ts b/client/src/reducers/citiesReducer.ts new file mode 100644 index 00000000..871123d8 --- /dev/null +++ b/client/src/reducers/citiesReducer.ts @@ -0,0 +1,14 @@ +import { AnyAction } from 'redux' +import Types from '../actions/types' +import { City } from '../interfaces/City' + +const initialState: City[] = [] + +export default function (state = initialState, action: AnyAction) { + switch (action.type) { + case Types.SET_CITIES: + return action.payload as City[] + default: + return state + } +} diff --git a/client/src/reducers/competitionsReducer.ts b/client/src/reducers/competitionsReducer.ts new file mode 100644 index 00000000..b3003d4a --- /dev/null +++ b/client/src/reducers/competitionsReducer.ts @@ -0,0 +1,45 @@ +import { AnyAction } from 'redux' +import Types from '../actions/types' +import { Competition } from '../interfaces/Competition' +import { CompetitionFilterParams } from './../interfaces/CompetitionFilterParams' + +interface CompetitionState { + competitions: Competition[] + total: number + count: number + filterParams: CompetitionFilterParams +} + +const initialState: CompetitionState = { + competitions: [], + total: 0, + count: 0, + filterParams: { pageSize: 10, page: 0 }, +} + +export default function (state = initialState, action: AnyAction) { + switch (action.type) { + case Types.SET_COMPETITIONS: + return { + ...state, + competitions: action.payload as Competition[], + } + case Types.SET_COMPETITIONS_FILTER_PARAMS: + return { + ...state, + filterParams: action.payload as CompetitionFilterParams, + } + case Types.SET_COMPETITIONS_TOTAL: + return { + ...state, + total: action.payload as number, + } + case Types.SET_COMPETITIONS_COUNT: + return { + ...state, + count: action.payload as number, + } + default: + return state + } +} diff --git a/client/src/reducers/uiReducer.ts b/client/src/reducers/uiReducer.ts index 8aac6daf..4d06d1e2 100644 --- a/client/src/reducers/uiReducer.ts +++ b/client/src/reducers/uiReducer.ts @@ -1,16 +1,27 @@ +import { AnyAction } from 'redux' import Types from '../actions/types' -const initialState = { + +interface UIError { + message: string +} + +interface UIState { + loading: boolean + errors: null | UIError +} + +const initialState: UIState = { loading: false, errors: null, } -export default function (state = initialState, action: any) { +export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_ERRORS: return { ...state, loading: false, - errors: action.payload, + errors: action.payload as UIError, } case Types.CLEAR_ERRORS: return { diff --git a/client/src/reducers/userReducer.ts b/client/src/reducers/userReducer.ts index fff6875b..b60221e6 100644 --- a/client/src/reducers/userReducer.ts +++ b/client/src/reducers/userReducer.ts @@ -1,13 +1,27 @@ //in userReducer.ts +import { AnyAction } from 'redux' import Types from '../actions/types' -const initialState = { +interface UserInfo { + name: string + email: string + roleId: number + cityId: number +} + +interface UserState { + authenticated: boolean + userInfo: UserInfo | null + loading: boolean +} + +const initialState: UserState = { authenticated: false, - credentials: {}, - loading: false, + loading: true, + userInfo: null, } -export default function (state = initialState, action: any) { +export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_AUTHENTICATED: return { @@ -20,7 +34,7 @@ export default function (state = initialState, action: any) { return { authenticated: true, loading: false, - ...action.payload, + userInfo: action.payload as UserInfo, } case Types.LOADING_USER: return { diff --git a/client/src/store.ts b/client/src/store.ts index bab656fa..8eec0a48 100644 --- a/client/src/store.ts +++ b/client/src/store.ts @@ -1,6 +1,6 @@ -import { applyMiddleware, compose, createStore } from 'redux' +import { AnyAction, applyMiddleware, compose, createStore } from 'redux' import { composeWithDevTools } from 'redux-devtools-extension' -import thunk from 'redux-thunk' +import thunk, { ThunkAction, ThunkDispatch } from 'redux-thunk' import allReducers from './reducers/allReducers' /* TypeScript does not know the type of the property. @@ -21,5 +21,8 @@ const middleware = [thunk] // simple store with plugin const store = createStore(allReducers, initialState, composeWithDevTools(applyMiddleware(...middleware))) +export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, RootState, unknown, AnyAction> +export type RootState = ReturnType<typeof store.getState> +export type AppDispatch = ThunkDispatch<RootState, void, AnyAction> export default store diff --git a/client/src/utils/SecureRoute.tsx b/client/src/utils/SecureRoute.tsx index 29b5aef5..df4eab9d 100644 --- a/client/src/utils/SecureRoute.tsx +++ b/client/src/utils/SecureRoute.tsx @@ -1,22 +1,21 @@ -import React, { useEffect, useState } from 'react' -import { connect } from 'react-redux' +import React, { useEffect } from 'react' import { Redirect, Route, RouteProps } from 'react-router-dom' +import { useAppSelector } from '../hooks' import { CheckAuthentication } from './checkAuthentication' interface SecureRouteProps extends RouteProps { login?: boolean - component: any - authenticated: boolean + component: React.ComponentType<any> rest?: any } /** Utility component to use for authentication, replace all routes that should be private with secure routes*/ -const SecureRoute: React.FC<SecureRouteProps> = ({ login, component: Component, authenticated, ...rest }: any) => { - const [isReady, setReady] = useState(false) +const SecureRoute: React.FC<SecureRouteProps> = ({ login, component: Component, ...rest }: SecureRouteProps) => { + const authenticated = useAppSelector((state) => state.user.authenticated) + const loading = useAppSelector((state) => state.user.loading) useEffect(() => { - CheckAuthentication().then(() => setReady(true)) + CheckAuthentication() }, []) - if (isReady) { - console.log(login, authenticated, Component) + if (!loading) { if (login) return ( <Route {...rest} render={(props) => (authenticated ? <Redirect to="/admin" /> : <Component {...props} />)} /> @@ -24,7 +23,4 @@ const SecureRoute: React.FC<SecureRouteProps> = ({ login, component: Component, else return <Route {...rest} render={(props) => (authenticated ? <Component {...props} /> : <Redirect to="/" />)} /> } else return null } -const mapStateToProps = (state: any) => ({ - authenticated: state.user.authenticated, -}) -export default connect(mapStateToProps)(SecureRoute) +export default SecureRoute diff --git a/client/src/utils/checkAuthentication.ts b/client/src/utils/checkAuthentication.ts index dbc3113a..41294b14 100644 --- a/client/src/utils/checkAuthentication.ts +++ b/client/src/utils/checkAuthentication.ts @@ -4,23 +4,30 @@ import Types from '../actions/types' import { logoutUser } from '../actions/user' import store from '../store' -const UnAuthorized = async () => { - store.dispatch(logoutUser()) +const UnAuthorized = () => { + logoutUser()(store.dispatch) } -export const CheckAuthentication = async () => { +export const CheckAuthentication = () => { const authToken = localStorage.token if (authToken) { const decodedToken: any = jwtDecode(authToken) if (decodedToken.exp * 1000 >= Date.now()) { axios.defaults.headers.common['Authorization'] = authToken - await axios + store.dispatch({ type: Types.LOADING_USER }) + console.log('loading user') + axios .get('/users') .then((res) => { store.dispatch({ type: Types.SET_AUTHENTICATED }) store.dispatch({ type: Types.SET_USER, - payload: res.data, + payload: { + name: res.data.name, + email: res.data.email, + roleId: res.data.role_id, + cityId: res.data.city_id, + }, }) }) .catch((error) => { -- GitLab