From 4f42fa52f0744bd66740709fb85140888cb138ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Honz=C3=ADk?= <honzikv@students.zcu.cz> Date: Mon, 2 May 2022 13:00:50 +0200 Subject: [PATCH 1/2] Login dialog + slice for notifications re #9628 --- frontend/src/App.tsx | 38 ++-- frontend/src/features/Auth/LoginDialog.tsx | 184 ++++++++++++++++++ .../features/Notification/Notification.tsx | 55 ++++++ .../Notification/notificationSlice.ts | 36 ++++ .../features/TrackingTool/TrackingTool.tsx | 11 +- frontend/src/features/redux/store.ts | 2 + 6 files changed, 304 insertions(+), 22 deletions(-) create mode 100644 frontend/src/features/Auth/LoginDialog.tsx create mode 100644 frontend/src/features/Notification/Notification.tsx create mode 100644 frontend/src/features/Notification/notificationSlice.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 322c683..e4adcda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,27 +13,31 @@ import Navigation from './features/Navigation/Navigation' import TrackingTool from './features/TrackingTool/TrackingTool' import Logout from './features/Auth/Logout' import ThemeWrapper from './features/Theme/ThemeWrapper' +import Notification from './features/Notification/Notification' +import { Fragment } from 'react' const App = () => { - return ( <ThemeWrapper> - <Navigation> - <Box sx={{mx: 10}}> - <Routes> - <Route path="/" element={<Home />} /> - <Route path="/catalog" element={<Catalog />} /> - <Route - path="/catalog/:itemId" - element={<CatalogItemDetail />} - /> - <Route path="/login" element={<Login />} /> - <Route path="/logout" element={<Logout />} /> - <Route path="/map" element={<TrackingTool />} /> - <Route path="*" element={<NotFound />} /> - </Routes> - </Box> - </Navigation> + <Fragment> + <Notification /> + <Navigation> + <Box sx={{ mx: 10 }}> + <Routes> + <Route path="/" element={<Home />} /> + <Route path="/catalog" element={<Catalog />} /> + <Route + path="/catalog/:itemId" + element={<CatalogItemDetail />} + /> + <Route path="/login" element={<Login />} /> + <Route path="/logout" element={<Logout />} /> + <Route path="/map" element={<TrackingTool />} /> + <Route path="*" element={<NotFound />} /> + </Routes> + </Box> + </Navigation> + </Fragment> </ThemeWrapper> ) } diff --git a/frontend/src/features/Auth/LoginDialog.tsx b/frontend/src/features/Auth/LoginDialog.tsx new file mode 100644 index 0000000..a6e759d --- /dev/null +++ b/frontend/src/features/Auth/LoginDialog.tsx @@ -0,0 +1,184 @@ +import { Fragment, FunctionComponent, useState } from 'react' +import Dialog, { DialogProps } from '@mui/material/Dialog' +import { + Button, + DialogContent, + Link, + Stack, + TextField, + Typography, +} from '@mui/material' +import { useFormik } from 'formik' +import * as yup from 'yup' +import { useDispatch } from 'react-redux' +import { showNotification } from '../Notification/notificationSlice' +import axiosInstance from '../../api/api' +import { Link as RouterLink } from 'react-router-dom' + +export interface CreateIndexDialogProps { + maxWidth?: DialogProps['maxWidth'] +} + +const RegisterDialog: FunctionComponent<CreateIndexDialogProps> = ({ + maxWidth, +}) => { + const [open, setOpen] = useState(false) + const [submitButtonEnabled, setSubmitButtonEnabled] = useState(true) + + const dispatch = useDispatch() + + const hideDialog = () => { + setOpen(false) + } + + const showDialog = () => { + setOpen(true) + } + + const validationSchema = yup.object().shape({ + email: yup.string().email().required('Email is required'), + password: yup.string().required('Password is required'), + }) + + const formik = useFormik({ + initialValues: { + name: '', + password: '', + }, + validationSchema, + onSubmit: async (values) => { + setSubmitButtonEnabled(false) + let userRegistered = false + try { + const { status } = await axiosInstance.post( + `/users/${values.name}`, + values + ) + + switch (status) { + case 200: + dispatch({ + message: 'User was created successfully', + severity: 'success', + }) + userRegistered = true + break + case 204: + dispatch( + showNotification({ + message: 'User already exists', + severity: 'error', + }) + ) + break + default: + dispatch({ + message: + 'Unknown error ocurred, the user was not registered. Please try again later', + severity: 'error', + }) + } + } catch (err: any) { + dispatch( + showNotification({ + message: 'The user could not be registered đźĄ', + severity: 'error', + }) + ) + } + + if (userRegistered) { + onClose() + } + + // Always fetch new indices + // TODO actually fetch the users + // dispatch(fetchUsers()) + setSubmitButtonEnabled(true) + }, + }) + + // Method called on closing the dialog + const onClose = () => { + hideDialog() + formik.resetForm() + } + + return ( + <Fragment> + <Stack + direction="row" + justifyContent="flex-end" + alignItems="center" + > + <Button variant="outlined" color="primary" onClick={showDialog}> + Create new Index + </Button> + </Stack> + + <Dialog + open={open} + fullWidth={true} + onClose={onClose} + maxWidth={maxWidth || 'lg'} + > + <Typography sx={{ ml: 2, mt: 2 }} variant="h5" fontWeight="600"> + Login + </Typography> + <DialogContent> + <form onSubmit={formik.handleSubmit}> + <TextField + fullWidth + label="Name" + name="name" + sx={{ mb: 2 }} + value={formik.values.name} + onChange={formik.handleChange} + error={ + Boolean(formik.errors.name) && + formik.touched.name + } + helperText={ + formik.errors.name && + formik.touched.name && + formik.errors.name + } + /> + <TextField + fullWidth + label="Password" + name="password" + type="password" + sx={{ mb: 2 }} + value={formik.values.password} + onChange={formik.handleChange} + error={ + Boolean(formik.errors.password) && + formik.touched.password + } + helperText={ + formik.errors.password && + formik.touched.password && + formik.errors.password + } + /> + <Fragment> + <Button + type="submit" + variant="contained" + disabled={!submitButtonEnabled} + fullWidth + > + Log in + </Button> + </Fragment> + </form> + + <Link component={RouterLink} to="/resetPassword">Forgot password?</Link> + </DialogContent> + </Dialog> + </Fragment> + ) +} + +export default RegisterDialog diff --git a/frontend/src/features/Notification/Notification.tsx b/frontend/src/features/Notification/Notification.tsx new file mode 100644 index 0000000..b9be95b --- /dev/null +++ b/frontend/src/features/Notification/Notification.tsx @@ -0,0 +1,55 @@ +import { Alert, AlertColor, Snackbar } from '@mui/material' +import { Fragment, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { RootState } from '../redux/store' +import { consumeNotification } from './notificationSlice' + +// Represents notification component that will be displayed on the screen +const Notification = () => { + const dispatch = useDispatch() + const notification = useSelector((state: RootState) => state.notification) + + const [displayMessage, setDisplayMessage] = useState('') + const [open, setOpen] = useState(false) + const [severity, setSeverity] = useState<AlertColor>('info') + const [autohideDuration, setAutohideDuration] = useState<number | null>( + null + ) + + const closeNotification = () => { + setOpen(false) + setAutohideDuration(null) + } + + // Set the message to be displayed if something is set + useEffect(() => { + if (notification.message) { + setDisplayMessage(notification.message) + setSeverity(notification.severity as AlertColor) + if (notification.autohideSecs) { + setAutohideDuration(notification.autohideSecs * 1000) + } + // Consume the message from store + dispatch(consumeNotification()) + + // Show the message in the notification + setOpen(true) + } + }, [notification, dispatch]) + + return ( + <Fragment> + <Snackbar + open={open} + autoHideDuration={autohideDuration} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + <Alert severity={severity} onClose={closeNotification}> + {displayMessage} + </Alert> + </Snackbar> + </Fragment> + ) +} + +export default Notification diff --git a/frontend/src/features/Notification/notificationSlice.ts b/frontend/src/features/Notification/notificationSlice.ts new file mode 100644 index 0000000..976c8ec --- /dev/null +++ b/frontend/src/features/Notification/notificationSlice.ts @@ -0,0 +1,36 @@ +import { AlertColor } from '@mui/material' +import { createSlice } from '@reduxjs/toolkit' + +export interface NotificationState { + message?: string + severity: AlertColor + autohideSecs?: number +} + +const initialState = { + message: undefined, + severity: 'info', + autohideSecs: undefined +} + +const notificationSlice = createSlice({ + name: 'notification', + initialState, + reducers: { + showNotification: (state, action) => ({ + ...state, + message: action.payload.message, + severity: action.payload.severity, + autohideSecs: action.payload.autohideSecs, + }), + // consumes the message so it is not displayed after the page gets refreshed + consumeNotification: (state) => ({ + ...initialState, + }), + }, +}) + +const notificationReducer = notificationSlice.reducer +export const { showNotification, consumeNotification } = + notificationSlice.actions +export default notificationReducer diff --git a/frontend/src/features/TrackingTool/TrackingTool.tsx b/frontend/src/features/TrackingTool/TrackingTool.tsx index bf2c1b3..35461b8 100644 --- a/frontend/src/features/TrackingTool/TrackingTool.tsx +++ b/frontend/src/features/TrackingTool/TrackingTool.tsx @@ -6,6 +6,8 @@ import mapConfig from '../../config/mapConfig' import TextPath from 'react-leaflet-textpath' import PlaintextUpload from './PlaintextUpload' import FileUpload from './FileUpload' +import L from 'leaflet' +import DeleteIcon from '@mui/icons-material/Delete' // Page with tracking tool const TrackingTool = () => { @@ -64,9 +66,7 @@ const TrackingTool = () => { repeat center weight={10} - > - <Popup>Caesar 🥗 War Path (Allegedly)</Popup> - </TextPath> + ></TextPath> ) } @@ -123,12 +123,13 @@ const TrackingTool = () => { url={mapConfig.url} /> {coords.map(({ latitude, longitude }, idx) => ( - <Marker position={[latitude, longitude]} /> + <Marker + position={[latitude, longitude]} + /> ))} {polylines} </MapContainer> </Grid> - </Grid> </Fragment> ) diff --git a/frontend/src/features/redux/store.ts b/frontend/src/features/redux/store.ts index e5bc471..e3297b6 100644 --- a/frontend/src/features/redux/store.ts +++ b/frontend/src/features/redux/store.ts @@ -5,6 +5,7 @@ import userReducer from '../Auth/userSlice' import themeReducer from '../Theme/themeSlice' import catalogReducer from '../Catalog/catalogSlice' import { composeWithDevTools } from 'redux-devtools-extension' +import notificationReducer from '../Notification/notificationSlice' const composeEnhancers = composeWithDevTools({}) @@ -14,6 +15,7 @@ const store = createStore( user: userReducer, theme: themeReducer, catalog: catalogReducer, + notification: notificationReducer }), process.env.REACT_APP_DEV_ENV === 'true' ? composeEnhancers( // ComposeEnhancers will inject redux-devtools-extension -- GitLab From 6129910f243ca34f79bd580afca37bb7e36678f9 Mon Sep 17 00:00:00 2001 From: Vaclav Honzik <vaclavhonzik98@gmail.com> Date: Thu, 5 May 2022 12:00:10 +0200 Subject: [PATCH 2/2] Show dialog in login page re #9628 --- frontend/src/features/Auth/Login.tsx | 86 +----------- frontend/src/features/Auth/LoginDialog.tsx | 131 +++++------------- frontend/src/features/Auth/RegisterDialog.tsx | 6 + frontend/src/features/Auth/userSlice.ts | 17 ++- frontend/src/features/Auth/userThunks.ts | 1 + .../Navigation/navigationMenuItems.ts | 4 +- frontend/src/features/Theme/ThemeChanger.tsx | 1 - 7 files changed, 63 insertions(+), 183 deletions(-) create mode 100644 frontend/src/features/Auth/RegisterDialog.tsx delete mode 100644 frontend/src/features/Theme/ThemeChanger.tsx diff --git a/frontend/src/features/Auth/Login.tsx b/frontend/src/features/Auth/Login.tsx index b4ece4b..9b90ef7 100644 --- a/frontend/src/features/Auth/Login.tsx +++ b/frontend/src/features/Auth/Login.tsx @@ -1,45 +1,16 @@ -import { Button, TextField, Typography } from '@mui/material' -import { useFormik } from 'formik' import { Fragment, useEffect } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import { useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom' -import * as yup from 'yup' -import { SchemaOf } from 'yup' import { RootState } from '../redux/store' -import { logIn } from './userThunks' +import LoginDialog from './LoginDialog' -interface LoginFields { - username: string - password: string -} const Login = () => { - const validationSchema: SchemaOf<LoginFields> = yup.object().shape({ - username: yup.string().required('Username is required'), - password: yup.string().required('Password is required'), - }) - - const dispatch = useDispatch() - const formik = useFormik({ - initialValues: { - username: '', - password: '', - }, - validationSchema, - onSubmit: () => { - dispatch( - logIn({ - username: formik.values.username, - password: formik.values.password, - }) - ) - }, - }) - - // Redirect to home if the user is logged in const userLoggedIn = useSelector( (state: RootState) => state.user.isLoggedIn ) + + // Redirect to home if the user is logged in const navigate = useNavigate() useEffect(() => { if (userLoggedIn) { @@ -49,54 +20,7 @@ const Login = () => { return ( <Fragment> - <Typography variant="h3">Login</Typography> - - <form onSubmit={formik.handleSubmit}> - <TextField - label="Username" - name="username" - fullWidth - sx={{ mb: 2 }} - value={formik.values.username} - onChange={formik.handleChange} - error={ - Boolean(formik.errors.username) && - formik.touched.username - } - helperText={ - formik.errors.username && - formik.touched.username && - formik.errors.username - } - /> - <TextField - type="password" - label="Password" - name="password" - fullWidth - value={formik.values.password} - onChange={formik.handleChange} - error={ - Boolean(formik.errors.password) && - formik.touched.password - } - helperText={ - formik.errors.password && - formik.touched.password && - formik.errors.password - } - sx={{ mb: 2 }} - /> - <Button - size="large" - variant="contained" - color="primary" - type="submit" - fullWidth - > - Login - </Button> - </form> + <LoginDialog /> </Fragment> ) } diff --git a/frontend/src/features/Auth/LoginDialog.tsx b/frontend/src/features/Auth/LoginDialog.tsx index a6e759d..fb4dd11 100644 --- a/frontend/src/features/Auth/LoginDialog.tsx +++ b/frontend/src/features/Auth/LoginDialog.tsx @@ -1,19 +1,19 @@ -import { Fragment, FunctionComponent, useState } from 'react' +import { Fragment, FunctionComponent, useEffect, useState } from 'react' import Dialog, { DialogProps } from '@mui/material/Dialog' import { Button, DialogContent, Link, - Stack, TextField, Typography, } from '@mui/material' import { useFormik } from 'formik' import * as yup from 'yup' -import { useDispatch } from 'react-redux' -import { showNotification } from '../Notification/notificationSlice' -import axiosInstance from '../../api/api' -import { Link as RouterLink } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' +import { Link as RouterLink, useNavigate } from 'react-router-dom' +import { logIn } from './userThunks' +import { RootState } from '../redux/store' +import { resetLoggingIn } from './userSlice' export interface CreateIndexDialogProps { maxWidth?: DialogProps['maxWidth'] @@ -22,105 +22,48 @@ export interface CreateIndexDialogProps { const RegisterDialog: FunctionComponent<CreateIndexDialogProps> = ({ maxWidth, }) => { - const [open, setOpen] = useState(false) - const [submitButtonEnabled, setSubmitButtonEnabled] = useState(true) + const [open, setOpen] = useState(true) const dispatch = useDispatch() - - const hideDialog = () => { - setOpen(false) - } - - const showDialog = () => { - setOpen(true) - } - + const navigate = useNavigate() + dispatch(resetLoggingIn()) const validationSchema = yup.object().shape({ - email: yup.string().email().required('Email is required'), + username: yup.string().required('Username is required'), password: yup.string().required('Password is required'), }) + const isLoggingIn = useSelector( + (state: RootState) => state.user.isLoggingIn + ) + const formik = useFormik({ initialValues: { - name: '', + username: '', password: '', }, validationSchema, - onSubmit: async (values) => { - setSubmitButtonEnabled(false) - let userRegistered = false - try { - const { status } = await axiosInstance.post( - `/users/${values.name}`, - values - ) - - switch (status) { - case 200: - dispatch({ - message: 'User was created successfully', - severity: 'success', - }) - userRegistered = true - break - case 204: - dispatch( - showNotification({ - message: 'User already exists', - severity: 'error', - }) - ) - break - default: - dispatch({ - message: - 'Unknown error ocurred, the user was not registered. Please try again later', - severity: 'error', - }) - } - } catch (err: any) { - dispatch( - showNotification({ - message: 'The user could not be registered đźĄ', - severity: 'error', - }) - ) - } - - if (userRegistered) { - onClose() - } - - // Always fetch new indices - // TODO actually fetch the users - // dispatch(fetchUsers()) - setSubmitButtonEnabled(true) + onSubmit: () => { + dispatch( + logIn({ + username: formik.values.username, + password: formik.values.password, + }) + ) }, }) - // Method called on closing the dialog - const onClose = () => { - hideDialog() + const onCancel = () => { formik.resetForm() + navigate('/') } return ( <Fragment> - <Stack - direction="row" - justifyContent="flex-end" - alignItems="center" - > - <Button variant="outlined" color="primary" onClick={showDialog}> - Create new Index - </Button> - </Stack> - <Dialog open={open} fullWidth={true} - onClose={onClose} - maxWidth={maxWidth || 'lg'} + onClose={onCancel} + maxWidth="md" > <Typography sx={{ ml: 2, mt: 2 }} variant="h5" fontWeight="600"> Login @@ -130,18 +73,18 @@ const RegisterDialog: FunctionComponent<CreateIndexDialogProps> = ({ <TextField fullWidth label="Name" - name="name" + name="username" sx={{ mb: 2 }} - value={formik.values.name} + value={formik.values.username} onChange={formik.handleChange} error={ - Boolean(formik.errors.name) && - formik.touched.name + Boolean(formik.errors.username) && + formik.touched.username } helperText={ - formik.errors.name && - formik.touched.name && - formik.errors.name + formik.errors.username && + formik.touched.username && + formik.errors.username } /> <TextField @@ -166,15 +109,17 @@ const RegisterDialog: FunctionComponent<CreateIndexDialogProps> = ({ <Button type="submit" variant="contained" - disabled={!submitButtonEnabled} fullWidth + disabled={isLoggingIn} > Log in </Button> </Fragment> - </form> + </form> - <Link component={RouterLink} to="/resetPassword">Forgot password?</Link> + <Link component={RouterLink} to="/resetPassword"> + Forgot password? + </Link> </DialogContent> </Dialog> </Fragment> diff --git a/frontend/src/features/Auth/RegisterDialog.tsx b/frontend/src/features/Auth/RegisterDialog.tsx new file mode 100644 index 0000000..8833c33 --- /dev/null +++ b/frontend/src/features/Auth/RegisterDialog.tsx @@ -0,0 +1,6 @@ +import { useFormik } from "formik" + +const register = () => { + + return <></> +} diff --git a/frontend/src/features/Auth/userSlice.ts b/frontend/src/features/Auth/userSlice.ts index bd85f6c..8b6b395 100644 --- a/frontend/src/features/Auth/userSlice.ts +++ b/frontend/src/features/Auth/userSlice.ts @@ -8,6 +8,7 @@ export interface UserState { refreshToken?: string username: string roles: string[] + isLoggingIn: boolean isLoggedIn: boolean lastErr?: string // consumable for errors during thunks } @@ -21,6 +22,7 @@ const persistConfig = { const initialState: UserState = { roles: [], isLoggedIn: false, + isLoggingIn: false, username: '', } @@ -41,27 +43,28 @@ export const userSlice = createSlice({ ...state, lastErr: action.payload, }), - setUserState: (state, action) => { - return ({ ...state, ...action.payload }) - }, + setUserState: (state, action) => ({ ...state, ...action.payload }), + resetLoggingIn: (state) => ({ ...state, isLoggingIn: false }), }, // Thunks extraReducers: (builder) => { builder.addCase(logIn.fulfilled, (state, action) => { - return ({ ...state, ...action.payload }) + return { ...state, ...action.payload } }) builder.addCase(logIn.rejected, (state, action) => { if (action.payload && typeof action.error.message === 'string') { - return ({ ...state, lastErr: action.error.message }) + return { ...state, lastErr: action.error.message } } }) + builder.addCase(logIn.pending, (state, action) => { + return { ...state, isLoggingIn: true } + }) }, }) - const userReducer = persistReducer(persistConfig, userSlice.reducer) -export const { logout, refreshTokens, setErr, setUserState } = userSlice.actions +export const { logout, refreshTokens, setErr, setUserState, resetLoggingIn } = userSlice.actions export default userReducer diff --git a/frontend/src/features/Auth/userThunks.ts b/frontend/src/features/Auth/userThunks.ts index a7a16d7..6bbf7d3 100644 --- a/frontend/src/features/Auth/userThunks.ts +++ b/frontend/src/features/Auth/userThunks.ts @@ -40,6 +40,7 @@ export const logIn = createAsyncThunk( refreshToken, username: sub, roles: authorities, + isLoggingIn: false, isLoggedIn: true } diff --git a/frontend/src/features/Navigation/navigationMenuItems.ts b/frontend/src/features/Navigation/navigationMenuItems.ts index 8a0bf34..6b1aef2 100644 --- a/frontend/src/features/Navigation/navigationMenuItems.ts +++ b/frontend/src/features/Navigation/navigationMenuItems.ts @@ -15,7 +15,8 @@ export interface NavigationMenuItem { // All privileges that can access this menu item accessibleTo: Set<string> icon: OverridableComponent<SvgIconTypeMap<{}, 'svg'>> - position: number + position: number, + isDialog?: boolean } const visitorRole = 'VISITOR' @@ -69,6 +70,7 @@ const items: NavigationMenuItem[] = [ accessibleTo: new Set([visitorRoleOnly]), icon: LoginIcon, position: 5, + isDialog: true }, { name: 'Statistics', diff --git a/frontend/src/features/Theme/ThemeChanger.tsx b/frontend/src/features/Theme/ThemeChanger.tsx deleted file mode 100644 index 56004c9..0000000 --- a/frontend/src/features/Theme/ThemeChanger.tsx +++ /dev/null @@ -1 +0,0 @@ -export default {} \ No newline at end of file -- GitLab