From 184b792263d21178cec730d4490a951566ee6fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Fugmann?= <andre.fugmann@rwth-aachen.de> Date: Tue, 11 Jun 2024 16:49:33 +0200 Subject: [PATCH] Add user management Users can request an anonymous token from the data backend. With this token they can save and delete projects associated with this token from the data backend. There are also some other small changes such as the currently selected tab snapping back to the first when selecting a different project. --- src/app/Layout/Navigation/NavigationBar.tsx | 2 + .../Layout/Navigation/NavigationDrawer.tsx | 12 +- src/app/Layout/Navigation/NewTokenDialog.tsx | 103 ++++++++++++++ src/app/Layout/Navigation/UserMenu.tsx | 46 ++++++- src/app/Layout/Navigation/UserTokenDialog.tsx | 86 ++++++++++++ src/app/Layout/Navigation/usertokenSlice.tsx | 97 +++++++++++++ .../soil-editor/SoilToolbar/SaveButton.tsx | 39 ++++++ .../soil-editor/SoilToolbar/SoilToolbar.tsx | 6 +- .../TopNavigationBar/AddFileModalContent.tsx | 11 +- .../TopNavigationBar/TopNavigationBar.tsx | 5 +- src/features/soil-editor/soileditorSlice.ts | 129 +++++++++++++++++- src/store/hooks.ts | 24 ++++ src/store/store.ts | 10 +- 13 files changed, 551 insertions(+), 19 deletions(-) create mode 100644 src/app/Layout/Navigation/NewTokenDialog.tsx create mode 100644 src/app/Layout/Navigation/UserTokenDialog.tsx create mode 100644 src/app/Layout/Navigation/usertokenSlice.tsx create mode 100644 src/features/soil-editor/SoilToolbar/SaveButton.tsx diff --git a/src/app/Layout/Navigation/NavigationBar.tsx b/src/app/Layout/Navigation/NavigationBar.tsx index 17b069a..fd2d33d 100644 --- a/src/app/Layout/Navigation/NavigationBar.tsx +++ b/src/app/Layout/Navigation/NavigationBar.tsx @@ -12,6 +12,7 @@ import PublicIcon from '@mui/icons-material/Public'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { drawerWidth } from './NavigationDrawer'; import { selectDarkmodeEnabled, toggleDarkmode } from './darkmodeSlice'; +import UserMenu from './UserMenu'; // NavigationBar component const NavigationBar = (): JSX.Element => { @@ -44,6 +45,7 @@ const NavigationBar = (): JSX.Element => { <div style={{ marginRight: "2rem" }}> <DarkModeSwitch checked={darkmodeEnabled} onClick={() => dispatch(toggleDarkmode())} /> </div> + <UserMenu/> </Toolbar> </AppBar> ); diff --git a/src/app/Layout/Navigation/NavigationDrawer.tsx b/src/app/Layout/Navigation/NavigationDrawer.tsx index 0d72d21..6c9e360 100644 --- a/src/app/Layout/Navigation/NavigationDrawer.tsx +++ b/src/app/Layout/Navigation/NavigationDrawer.tsx @@ -10,9 +10,11 @@ import LogoLight from '../../../assets/SOIL-logo-tight-light.png'; import Logo from '../../../assets/SOIL-logo-tight.png'; import AddProjectModal from '../../../features/soil-editor/AddProject/AddProject'; import DeleteFileDialog from '../../../features/soil-editor/TopNavigationBar/DeleteFileDialog'; -import { removeProject, selectProjects } from '../../../features/soil-editor/soileditorSlice'; +import { deleteProjectById, removeProject, selectProjects } from '../../../features/soil-editor/soileditorSlice'; import { useAppDispatch, useAppSelector } from "../../../store/hooks"; import { selectDarkmodeEnabled } from './darkmodeSlice'; +import { setCurrentTab } from '../../../features/soil-editor/TopNavigationBar/topnavigationSlice'; +import { usertokenState } from './usertokenSlice'; export const drawerWidth = 240; @@ -22,6 +24,7 @@ const NavigationDrawer = (): JSX.Element => { const [open, setOpen] = React.useState(true); const [dialogOpen, setDialogOpen] = React.useState([false]); const darkmodeEnabled = useAppSelector(selectDarkmodeEnabled) + const usertoken = useAppSelector(usertokenState); const dispatch = useAppDispatch(); // Toggle the list of projects @@ -68,14 +71,17 @@ const NavigationDrawer = (): JSX.Element => { Object.keys(soilProjects).map((value, index) => { return <div key={"ListProject" + value}> {/* Delete file dialog */} - <DeleteFileDialog open={!!dialogOpen[index]} setOpen={(value: boolean) => { handleDialog(index) }} filename={soilProjects[value].name} deleteFile={(filename: string) => { dispatch(removeProject(value)) }} /> + <DeleteFileDialog open={!!dialogOpen[index]} setOpen={(value: boolean) => { handleDialog(index) }} filename={soilProjects[value].name} deleteFile={(filename: string) => + { dispatch(removeProject(value)); + if (usertoken.logged_in && usertoken.token) dispatch(deleteProjectById(value)); + }} /> {/* Project list item */} <ListItem secondaryAction={ <IconButton onClick={(e) => { handleDialog(index) }} edge="end" aria-label="delete"> <DeleteIcon /> </IconButton> }> - <ListItemButton component={Link} to={"/project/" + value} key={value} sx={{paddingLeft: '8pt'}}> + <ListItemButton component={Link} to={"/project/" + value} key={value} sx={{paddingLeft: '8pt'}} onClick={() => {dispatch(setCurrentTab(0))}}> <ListItemIcon sx={{minWidth: 48}}> <CircleIcon /> </ListItemIcon> diff --git a/src/app/Layout/Navigation/NewTokenDialog.tsx b/src/app/Layout/Navigation/NewTokenDialog.tsx new file mode 100644 index 0000000..3ffa7d8 --- /dev/null +++ b/src/app/Layout/Navigation/NewTokenDialog.tsx @@ -0,0 +1,103 @@ +import { Button, Checkbox, ClickAwayListener, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton, TextField, Tooltip } from '@mui/material' +import React, { useCallback, useEffect, useState } from 'react'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { getNewToken, usertokenState } from './usertokenSlice'; + +type Props = { + open: boolean; + onClose: ()=>void; +} + +const NewTokenDialog = ({open, onClose}: Props) => { + const dispatch = useAppDispatch(); + const usertoken = useAppSelector(usertokenState); + + const [checked, setChecked] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); + const [newToken, setNewToken] = useState(""); + + useEffect(() => { + if(usertoken.token) setNewToken(usertoken.token); + }, [usertoken]) + + const onGenerate = () => { + dispatch(getNewToken()); + } + + const handleChange = () => { + const currentChecked = checked; + setChecked(!currentChecked); + } + + const handleTooltipClose = () => { + setTooltipOpen(false); + }; + + return ( + <Dialog open={open} onClose={onClose} fullWidth={true} maxWidth={'lg'}> + <DialogTitle> + Generate new token + </DialogTitle> + <DialogContent> + <DialogContentText marginBottom={2}> + To use this site correctly you need a generated usertoken. + </DialogContentText> + <DialogContentText marginBottom={2}> + You will need to enter it on the previous dialog if you are logged out. (E.g. you are using this website from a different device or using a different browser) + </DialogContentText> + <DialogContentText marginBottom={2}> + If you do not have a token yet you can create a new one here. + </DialogContentText> + <DialogContentText> + IMPORTANT: The token is NOT recoverable, if you lose it you will lose any progress associated with it. Make sure to save it locally or write it down. + </DialogContentText> + <FormControlLabel + control={<Checkbox checked={checked} onChange={handleChange}/>} label={<div>I am aware that this token cannot be recovered and have made a copy of it.</div>} + /> + <TextField + margin='normal' + id="tokentf" + label="Usertoken" + value={newToken} + type="token" + fullWidth + variant="outlined" + disabled={true} + InputProps={{endAdornment: + <ClickAwayListener onClickAway={handleTooltipClose}> + <div> + <Tooltip + PopperProps={{ + disablePortal: true, + }} + onClose={handleTooltipClose} + open={tooltipOpen} + disableFocusListener + disableHoverListener + disableTouchListener + title="Copied to clipboard" + > + <IconButton onClick={() => { + navigator.clipboard.writeText(newToken); + setTooltipOpen(true); + }}> + <ContentCopyIcon/> + </IconButton> + </Tooltip> + </div> + </ClickAwayListener> + }} + /> + <Button disabled={!checked} variant={'contained'} color={'success'} onClick={onGenerate}> + Generate + </Button> + </DialogContent> + <DialogActions> + <Button onClick={onClose}>Close</Button> + </DialogActions> + </Dialog> + ) +} + +export default NewTokenDialog \ No newline at end of file diff --git a/src/app/Layout/Navigation/UserMenu.tsx b/src/app/Layout/Navigation/UserMenu.tsx index e643040..7830a3c 100644 --- a/src/app/Layout/Navigation/UserMenu.tsx +++ b/src/app/Layout/Navigation/UserMenu.tsx @@ -1,19 +1,46 @@ import { AccountCircle } from '@mui/icons-material'; -import { IconButton, Menu } from '@mui/material'; +import { Badge, IconButton, Menu, MenuItem } from '@mui/material'; import { useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { logout, usertokenState } from './usertokenSlice'; +import UserTokenDialog from './UserTokenDialog'; +import JSZip from 'jszip'; +import { selectProjects } from '../../../features/soil-editor/soileditorSlice'; +import FileSaver from 'file-saver'; const UserMenu = (): JSX.Element => { const [anchorEl, setAnchorEl] = useState(null); + const usertoken = useAppSelector(usertokenState); + const projects = useAppSelector(selectProjects); + const [open, setOpen] = useState(false); + const dispatch = useAppDispatch(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleMenuClick = (event: any) => { setAnchorEl(event.currentTarget); }; - + const downloadAllProjects = () => { + const zip = new JSZip(); + Object.entries(projects).forEach(([projectName, project]) => { + Object.entries(project.files).forEach(([fileName, file]) => { + zip.file(project.name + "/" + fileName + ".soil", file.textModel); + }); + }); + zip.generateAsync({ type: 'blob' }).then(function (content) { + const timestamp = new Date().toISOString(); + if(usertoken.token) FileSaver.saveAs(content, timestamp + "+" + usertoken.token + '.zip'); + }); + }; return ( <div> + <Badge + invisible={usertoken.logged_in} + badgeContent={'!'} + color={'error'} + overlap={"circular"} + sx={{mr: 3}}> <IconButton size="large" aria-controls="menu-appbar" @@ -27,8 +54,8 @@ const UserMenu = (): JSX.Element => { id="menu-appbar" anchorEl={anchorEl} anchorOrigin={{ - vertical: 'top', - horizontal: 'right', + vertical: 'center', + horizontal: 'center', }} keepMounted transformOrigin={{ @@ -38,7 +65,18 @@ const UserMenu = (): JSX.Element => { open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)} > + <MenuItem onClick={() => {setOpen(true)}}> + {usertoken.logged_in ? ( + <>Logged in as: {usertoken.token}</> + ) : ( + <>Login</> + )} + </MenuItem> + {usertoken.logged_in && <MenuItem onClick={() => {dispatch(logout())}}>Logout</MenuItem>} + {usertoken.logged_in && <MenuItem onClick={() => {downloadAllProjects()}}>Download all projects</MenuItem>} + <UserTokenDialog open={open} onClose={()=>{setOpen(false)}}></UserTokenDialog> </Menu> + </Badge> </div> ); }; diff --git a/src/app/Layout/Navigation/UserTokenDialog.tsx b/src/app/Layout/Navigation/UserTokenDialog.tsx new file mode 100644 index 0000000..1bb54b6 --- /dev/null +++ b/src/app/Layout/Navigation/UserTokenDialog.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Button, Dialog, Link, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, TextField, Typography } from '@mui/material' +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import NewTokenDialog from './NewTokenDialog'; +import { clearError, login, usertokenState } from './usertokenSlice'; + +type Props = { + open: boolean; + onClose: () => void; +}; + +const UserTokenDialog = ({open, onClose}: Props): JSX.Element => { + const [tokenGenDialogOpen, setTokenGenDialogOpen] = useState(false); + const [tfValue, setTfValue] = useState(""); + const dispatch = useAppDispatch(); + const usertoken = useAppSelector(usertokenState) + + useEffect(()=>{ + if (usertoken.logged_in) handleClose(); + }, [usertoken]); + + const handleClose = () => { + setTfValue(""); + dispatch(clearError()); + onClose(); + } + + const onLogin = async () => { + try { + dispatch(login(tfValue)); + } + catch (error) { + + } + }; + + return ( + <Dialog open={open} onClose={handleClose}> + <DialogTitle>User Login</DialogTitle> + <DialogContent> + <DialogContentText> + {usertoken.logged_in ? ( + <DialogContentText> + <DialogContentText marginBottom={3}>Logged in as: <i>{usertoken.token}</i></DialogContentText> + <DialogContentText>If you want to change the login token, please enter a different one and click "Login".</DialogContentText> + </DialogContentText> + ) : ( + <DialogContentText> + <DialogContentText marginBottom={3}>Not logged in</DialogContentText> + <DialogContentText>You are currently not logged in. To login, please enter your usertoken and click on "Login".</DialogContentText> + </DialogContentText> + )} + </DialogContentText> + <TextField + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + setTfValue(event.target.value); + }} + margin='normal' + id="name" + label="Usertoken" + type="token" + value={tfValue} + fullWidth + variant="outlined" + /> + {usertoken.error && <Typography color={"common.red"}>This token does not exist!</Typography>} + <Link href="#" onClick={()=>{setTokenGenDialogOpen(true)}}> + I don't have a usertoken. + </Link> + </DialogContent> + {!usertoken.logged_in ? ( + <DialogActions> + <Button onClick={handleClose}>Cancel</Button> + <Button onClick={onLogin}>Login</Button> + </DialogActions> + ) : ( + <DialogActions> + <Button onClick={handleClose}>Close</Button> + <Button onClick={onLogin}>Login</Button> + </DialogActions>)} + <NewTokenDialog open={tokenGenDialogOpen} onClose={()=>{setTokenGenDialogOpen(false)}}/> + </Dialog> + ) +} + +export default UserTokenDialog \ No newline at end of file diff --git a/src/app/Layout/Navigation/usertokenSlice.tsx b/src/app/Layout/Navigation/usertokenSlice.tsx new file mode 100644 index 0000000..e24d232 --- /dev/null +++ b/src/app/Layout/Navigation/usertokenSlice.tsx @@ -0,0 +1,97 @@ +import { PayloadAction, createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { RootState } from "../../../store/store"; +import { DATA_BACKEND } from "../../../const"; + +type userToken = { + logged_in: boolean; + token: string | null; + error: string | null; +} + +const initializer = (): userToken => { + if(localStorage.getItem("token") !== null) { + return { + logged_in: true, + token: localStorage.getItem("token"), + error: null + }; + } + else { + return { + logged_in: false, + token: null, + error: null + }; + } +} + +export const getNewToken = createAsyncThunk<string, void, { state: RootState }>( + 'user/getNewToken', + async () => { + var myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/json"); + let response = await fetch(DATA_BACKEND + "/user/new/", { + method: 'GET', + headers: myHeaders, + }).then(rsp => rsp.text().then(text => {return JSON.parse(text).token})).catch(error => error.text()); + return response; + } +); + +// Queries the data-backend whether the specified token exists +export const login = createAsyncThunk<boolean, string, { state: RootState }>( + 'user/validateToken', + async (token, thunkAPI) => { + var myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/json"); + let response = await fetch(DATA_BACKEND + `/user/${token}/`, { + method: 'GET', + headers: myHeaders, + }).then(rsp => rsp.text().then(text => {return JSON.parse(text).value})).catch(error => error.text()); + console.log(response); + console.log("I'm executed!") + if ( response === true ) return response; + else return thunkAPI.rejectWithValue("User does not exist!"); + } +) + +const usertokenSlice = createSlice({ + name: 'usertoken', + initialState: initializer(), + reducers: { + logout(state, action: PayloadAction<void>) { + console.log("test123"); + localStorage.removeItem("token"); + state.logged_in = false; + state.token = null; + state.error = null; + }, + clearError(state, action: PayloadAction<void>) { + state.error = null; + } + }, + extraReducers: (builder) => { + builder.addCase(getNewToken.fulfilled, (state, action) => { + state.token = action.payload; + }); + builder.addCase(getNewToken.rejected, (state, action) => { + + }); + builder.addCase(login.fulfilled, (state, action) => { + state.token = action.meta.arg + state.logged_in = action.payload + state.error = null; + localStorage.setItem("token", action.meta.arg); + }); + builder.addCase(login.rejected, (state, action) => { + if(action.error) { + state.error = action.payload as string; + } + }) + } +}) + +export const { logout, clearError } = usertokenSlice.actions; +export const usertokenState = (state: RootState) => state.usertoken; + +export default usertokenSlice.reducer; \ No newline at end of file diff --git a/src/features/soil-editor/SoilToolbar/SaveButton.tsx b/src/features/soil-editor/SoilToolbar/SaveButton.tsx new file mode 100644 index 0000000..754d8ad --- /dev/null +++ b/src/features/soil-editor/SoilToolbar/SaveButton.tsx @@ -0,0 +1,39 @@ +import { Button, Stack } from '@mui/material' +import React, { useEffect } from 'react' +import SaveIcon from '@mui/icons-material/Save'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { saveProject, saveProjectById, selectProjects } from '../soileditorSlice'; +import { usertokenState } from '../../../app/Layout/Navigation/usertokenSlice'; + +const SaveButton = (props: { projectId: string }) => { + const dispatch = useAppDispatch(); + const projects = useAppSelector(selectProjects); + const token = useAppSelector(usertokenState); + + useEffect(() => { + const handleKeyPress = (event: any) => { + if (event.key === 's' && event.ctrlKey) { + event.preventDefault(); + if(token.logged_in) handleSave(); + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, []) + + const handleSave = () => { + // Project is saved locally + dispatch(saveProject(props.projectId)); + + // Project is saved in the database + if (token.logged_in && token.token) dispatch(saveProjectById([props.projectId, projects[props.projectId].name, projects[props.projectId].version, token.token])); + } + + return ( + <Button startIcon={<SaveIcon/>} onClick={handleSave} disabled={token.logged_in ? false : true}>Save</Button> + ) +} + +export default SaveButton \ No newline at end of file diff --git a/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx b/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx index 24e1ef8..eb3ebfe 100644 --- a/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx +++ b/src/features/soil-editor/SoilToolbar/SoilToolbar.tsx @@ -16,6 +16,7 @@ import GenerateButton from './GenerateButton'; import PublishModal from './PublishModal'; import VerifyButton from './VerifyButton'; import { toggleErrorLog } from './toolbarSlice'; +import SaveButton from './SaveButton'; const SoilToolbar = (): JSX.Element => { @@ -98,13 +99,14 @@ const SoilToolbar = (): JSX.Element => { <Tooltip title="Generate this Project"> <GenerateButton /> </Tooltip> + <SaveButton projectId={projectId} /> </Stack> <Stack style={{ marginLeft: "auto" }} direction="row" spacing={1}> <Button onClick={() => dispatch(toggleErrorLog())} variant='outlined' startIcon={<ErrorIcon />}>Error Log</Button> - {/* {<Tooltip title={"Switch visualization."}> + <Tooltip title={"Switch visualization."}> {graphSwitch} - </Tooltip>} */} + </Tooltip> </Stack> </Toolbar> ); diff --git a/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx b/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx index eb14777..dafe67a 100644 --- a/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx +++ b/src/features/soil-editor/TopNavigationBar/AddFileModalContent.tsx @@ -60,7 +60,6 @@ export default function AddFileModalContent({ handleClose, projectId }: AddFileM setValue(newValue); }; - function handleCreateProject() { if (checkIfFileExists(uploadedFile)) { return @@ -89,6 +88,11 @@ export default function AddFileModalContent({ handleClose, projectId }: AddFileM const target = event.target as HTMLInputElement; const files = target.files; if (files !== null) { + if(/\s/g.test(files[0].name)) { + setSnackbarMessage("The Filename can not include space characters.") + setShowSnackbar(true); + return + } setUploadedFile(files[0].name) let reader = new FileReader(); reader.onload = handleFileLoad; @@ -103,6 +107,11 @@ export default function AddFileModalContent({ handleClose, projectId }: AddFileM setShowSnackbar(true); return } + if (/\s/g.test(newFileName)) { + setSnackbarMessage("The Filename can not include space characters.") + setShowSnackbar(true); + return + } if (checkIfFileExists(newFileName)) { return } diff --git a/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx b/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx index 268c009..631b8b8 100644 --- a/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx +++ b/src/features/soil-editor/TopNavigationBar/TopNavigationBar.tsx @@ -17,7 +17,8 @@ function asignProps(index: number) { type File = { fileName: string, textModel: string, - graphModel: Object + graphModel: Object, + changed: boolean } type TopNavigationBarProps = { @@ -64,7 +65,7 @@ export default function TopNavigationBar({ files }: TopNavigationBarProps) { <Tabs value={currentTab} onChange={handleChange} aria-label="basic tabs example"> { Object.values(files).map((file, index) => { - return <Tab key={index + "TNB"} label={<div>{file.fileName} <CloseIcon fontSize='small' color='action' onClick={(e) => { handleDeleteClick(e, file.fileName) }} /></div>} {...asignProps(index)}></Tab> + return <Tab style={{textTransform: 'none'}} key={index + "TNB"} label={<div>{file.changed ? ("*" + file.fileName) : (file.fileName)} <CloseIcon fontSize='small' color='action' onClick={(e) => { handleDeleteClick(e, file.fileName) }} /></div>} {...asignProps(index)}></Tab> }) } diff --git a/src/features/soil-editor/soileditorSlice.ts b/src/features/soil-editor/soileditorSlice.ts index 76919c9..e9e1a00 100644 --- a/src/features/soil-editor/soileditorSlice.ts +++ b/src/features/soil-editor/soileditorSlice.ts @@ -5,6 +5,7 @@ import FileSaver from 'file-saver'; import { v4 as uuidv4 } from 'uuid'; import { DATA_BACKEND, SOIL_BACKEND } from '../../const'; import { RootState } from '../../store/store'; +import { access, stat } from 'fs'; export type GenerationSetting = { projectId: string; @@ -121,6 +122,7 @@ export type File = { errors: Error[]; warnings: Object[]; }; + changed: boolean; }; // Define the Project type @@ -133,6 +135,7 @@ export type Project = { }; showTextModel: boolean; loading: boolean; + timestamp: string; }; // Define the SoileditorState interface @@ -168,6 +171,13 @@ type GenerationReportResponse = { generationReport: GenerationReport; }; +type DataBackendGetProjectsResponse = { + id: string; + project: Project; + projectName: string; + version: string; +} + // Initialize the state for the soileditor slice const initialState: SoileditorState = { snackbarInfo: { @@ -273,6 +283,69 @@ export const validateModelById = createAsyncThunk<GenerationReportResponse, stri } ); + +export const saveProjectById = createAsyncThunk<string, string[], { state: RootState }>( + 'projects/saveProjectById', + async ([projectId, name, version, usertoken], thunkAPI) => { + const state = thunkAPI.getState().soileditor; + var myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/json"); + myHeaders.append("Authorization", usertoken); + let response = await fetch(DATA_BACKEND + "/projects", { + method: 'POST', + headers: myHeaders, + body: JSON.stringify({ id: projectId, project: state.projects[projectId], version: version, projectName: name }), + redirect: 'follow', + }) + .then(rsp => rsp.text()) + .catch(error => error.text()); + return response; + } +); + +export const deleteProjectById = createAsyncThunk<string, string, { state: RootState }>( + 'projects/deleteProjectById', + async (projectId, thunkAPI) => { + var myHeaders = new Headers(); + const usertoken = thunkAPI.getState().usertoken.token; + if (usertoken === null) return; + myHeaders.append("Content-Type", "application/json"); + myHeaders.append("Authorization", usertoken); + let response = await fetch(DATA_BACKEND + "/projects/" + projectId + "/", { + method: 'DELETE', + headers: myHeaders, + redirect: 'follow', + }) + .then(rsp => rsp.text()) + .catch(error => error.text()); + return response; + } +); + +// Fetches all existing projects from the database and synchronizes them with the local state. +// This method should only be called after the store was rehydrated while the user is logged in +// or after a login was performed. +export const getProjectsFromDatabase = createAsyncThunk<DataBackendGetProjectsResponse[], void, { state: RootState }>( + 'projects/getProjectsFromDatabase', + async (_, thunkAPI) => { + const user = thunkAPI.getState().usertoken; + + if (user.token == null) return thunkAPI.rejectWithValue("Tried to fetch projects from database without a token!"); + + var myHeaders = new Headers(); + myHeaders.append("Authorization", user.token); + let response = await fetch(DATA_BACKEND + "/projects", { + method: 'GET', + headers: myHeaders, + redirect: 'follow', + }) + .then(rsp => {return rsp.text()}).then(text => {return JSON.parse(text)}) + .catch(error => error.text()); + return response; + } +); + + // Define the soileditorSlice, which contains the reducers for manipulating the state. export const soileditorSlice = createSlice({ // Set the name of the slice. @@ -284,11 +357,11 @@ export const soileditorSlice = createSlice({ // Add a new project to the state with a given text model and filename. addTextModel: (state, action: PayloadAction<string[]>) => { const newUUID = uuidv4() - state.projects[newUUID] = { name: action.payload[0], projectId: newUUID, files: {}, version: "1.0", showTextModel: true, loading: false } + state.projects[newUUID] = { name: action.payload[0], projectId: newUUID, files: {}, version: "1.0", showTextModel: true, loading: false, timestamp: new Date().toISOString() } const files = state.projects[newUUID].files; var counter = 1; while (counter < action.payload.length - 1) { - files[action.payload[counter]] = { cardsOpen: [false], imports: [], textModel: action.payload[counter + 1], graphModel: [], fileName: action.payload[counter], generationReport: { errors: [], warnings: [] } } + files[action.payload[counter]] = { cardsOpen: [false], imports: [], textModel: action.payload[counter + 1], graphModel: [], fileName: action.payload[counter], generationReport: { errors: [], warnings: [] }, changed: false } counter += 2; } @@ -311,7 +384,8 @@ export const soileditorSlice = createSlice({ fileName: action.payload[1], graphModel: [], textModel: action.payload[2], - generationReport: { errors: [], warnings: [] } + generationReport: { errors: [], warnings: [] }, + changed: false } }, // Remove a project from the state. @@ -360,7 +434,9 @@ export const soileditorSlice = createSlice({ if (state.projects[action.payload[0]]) { state.projects[action.payload[0]].loading = false; if (state.projects[action.payload[0]].files[action.payload[1]]) { - state.projects[action.payload[0]].files[action.payload[1]].textModel = action.payload[2] + if (state.projects[action.payload[0]].files[action.payload[1]].textModel !== action.payload[2]) + state.projects[action.payload[0]].files[action.payload[1]].changed = true; + state.projects[action.payload[0]].files[action.payload[1]].textModel = action.payload[2]; } } }, @@ -437,6 +513,13 @@ export const soileditorSlice = createSlice({ }; state.projects[action.payload[0]].files[action.payload[1]].graphModel.push(newComponent) }, + setFileChanged: (state, action: PayloadAction<any[]>) => { + state.projects[action.payload[0]].files[action.payload[1]].changed = true; + }, + saveProject: (state, action: PayloadAction<string>) => { + Object.entries(state.projects[action.payload].files).forEach(([filename, file]) => file.changed = false); + state.projects[action.payload].timestamp = new Date().toISOString(); + } }, extraReducers: (builder) => { // Add a case for when the `generateModelById` action is fulfilled @@ -548,10 +631,46 @@ export const soileditorSlice = createSlice({ state.snackbarInfo.open = true }); + builder.addCase(saveProjectById.fulfilled, (state, action) => { + state.snackbarInfo.severity = "success"; + state.snackbarInfo.content = "Project saved in database."; + state.snackbarInfo.open = true; + }); + builder.addCase(saveProjectById.rejected, (state, action) => { + state.snackbarInfo.severity = "error"; + state.snackbarInfo.content = "There was an error saving your project"; + state.snackbarInfo.open = true; + }); + builder.addCase(deleteProjectById.fulfilled, (state, action) => { + state.snackbarInfo.severity = "success"; + state.snackbarInfo.content = "Project deleted successfully."; + state.snackbarInfo.open = true; + }); + builder.addCase(deleteProjectById.rejected, (state, action) => { + state.snackbarInfo.severity = "error"; + state.snackbarInfo.content = "There was a problem deleting your project"; + state.snackbarInfo.open = true; + }); + builder.addCase(getProjectsFromDatabase.rejected, (state, action) => { + state.snackbarInfo.severity = "error"; + state.snackbarInfo.content = "There was an error fetching your projects from the database"; + state.snackbarInfo.open = true; + }); + builder.addCase(getProjectsFromDatabase.fulfilled, (state, action) => { + action.payload.forEach(proj => { + if(state.projects[proj.id] !== undefined) { + // TODO: implement logic to ask if user wants newer project state instead of just overwriting the local state + if(state.projects[proj.id].timestamp < proj.project.timestamp) state.projects[proj.id] = proj.project; + } + else { + state.projects[proj.id] = proj.project; + } + }); + }); }, }); -export const { addTextModel, addFileToProject, removeFileFromProject, setGraphModel, setImports, addEnumToFile, addFunctionToFile, addMeasurementToFile, addParameterToFile, addComponentToFile, deleteSoilElementFromGraph, addImportToFile, removeImportFromFile, setCardsOpen, toggleSnackbar, changeTextOfFile, importProject, removeProject, setGraphModelCard, toggleModelRepresentation } = soileditorSlice.actions; +export const { addTextModel, addFileToProject, removeFileFromProject, setGraphModel, setImports, addEnumToFile, addFunctionToFile, addMeasurementToFile, addParameterToFile, addComponentToFile, deleteSoilElementFromGraph, addImportToFile, removeImportFromFile, setCardsOpen, toggleSnackbar, changeTextOfFile, importProject, removeProject, setGraphModelCard, toggleModelRepresentation, saveProject } = soileditorSlice.actions; export const selectProjects = (state: RootState) => state.soileditor.projects; export const selectSnackbarInfo = (state: RootState) => state.soileditor.snackbarInfo; diff --git a/src/store/hooks.ts b/src/store/hooks.ts index 520e84e..fc1a1cf 100644 --- a/src/store/hooks.ts +++ b/src/store/hooks.ts @@ -1,6 +1,30 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from './store'; +import { useEffect, useRef } from 'react'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; + +export function useKey(key: any, cb: any) { + const callback = useRef(cb); + + useEffect(() => { + callback.current = cb; + }) + + useEffect(() => { + function handle(event: any) { + if (event.code === key) { + callback.current(event); + } + else if (key === "ctrls" && event.key === 's' && event.ctrlKey) { + callback.current(event); + } + + document.addEventListener('keydown', handle); + return () => document.removeEventListener('keydown', handle); + } + }, [key]); + +} diff --git a/src/store/store.ts b/src/store/store.ts index 0a93f27..3d7a40b 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -3,9 +3,10 @@ import { Action, ThunkAction, combineReducers, configureStore, getDefaultMiddlew import { persistReducer, persistStore } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import darkmodeReducer from '../app/Layout/Navigation/darkmodeSlice'; +import usertokenReducer from '../app/Layout/Navigation/usertokenSlice'; import toolbarReducer from '../features/soil-editor/SoilToolbar/toolbarSlice'; import topnavigationReducer from '../features/soil-editor/TopNavigationBar/topnavigationSlice'; -import soileditorReducer from '../features/soil-editor/soileditorSlice'; +import soileditorReducer, { getProjectsFromDatabase } from '../features/soil-editor/soileditorSlice'; // Configuration for persisting the Redux store const persistConfig = { @@ -13,10 +14,15 @@ const persistConfig = { storage, } +const onRehydrate = () => { + store.dispatch(getProjectsFromDatabase()) +} + // Combine reducers and create a persisted reducer const persistedReducer = persistReducer(persistConfig, combineReducers({ toolbar: toolbarReducer, darkmode: darkmodeReducer, + usertoken: usertokenReducer, soileditor: soileditorReducer, topnavigation: topnavigationReducer, })) @@ -35,7 +41,7 @@ export const store = configureStore({ }) // Create a persistor for the Redux store -export const persistor = persistStore(store) +export const persistor = persistStore(store, null, onRehydrate); // Define types for dispatch, state, and thunks export type AppDispatch = typeof store.dispatch; -- GitLab