diff --git a/src/app/Layout/Navigation/NavigationBar.tsx b/src/app/Layout/Navigation/NavigationBar.tsx index 17b069a0cd6fb68120b9f939c145ed2d32ea5168..fd2d33db38376dd03d867d4d47e22e5031599fb8 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 0d72d2147e7e43d3e88fb5209f31e4be2897a1b6..6c9e3606d22d52288779e9d117546fec2f8ba1f4 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 0000000000000000000000000000000000000000..3ffa7d85bb30bff903498bbd5259129a56a1d855 --- /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 e64304044315c73485f1068454d4c2ad8eac6bb1..7830a3c7ba2c5b089ac691a68cd4c47165028bd8 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 0000000000000000000000000000000000000000..1bb54b6bfb9b65d4f101e9d01a190672db7e8fc6 --- /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 0000000000000000000000000000000000000000..e24d2327b6f05e31cf5d21edda4d6c5d070a50e4 --- /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 0000000000000000000000000000000000000000..754d8ad575352040b4d1b5cf56ec432c172a9ae6 --- /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 24e1ef86a7e7bd3188f6f494018a57fb0f664ce2..eb3ebfe778444661e4a32d8345f7c9b53037d7da 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 eb1477716f126c768a40a44c1a23d474220b43e1..dafe67af8a2452788b7ca0149f1f08041ec2b34e 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 268c00908987e1285ffa50a464401fa1e0c39f7f..631b8b83fc7fa54ad835c011012b1330d0a6f52c 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 76919c93b0c7a66d0650334674c572c2c27f1fb5..e9e1a0032c5afff6dc97dac6e25ad1ee891d6ad8 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 520e84ed52505e591bfe7b33196a9fd77ae1df9d..fc1a1cffeaf212edc83063a9c0d3ce84026bd2f0 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 0a93f2720402c8f253b1f65399489c3f6d05c6e1..3d7a40b3b2b776d959802021239e5785ed9cf0bd 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;