[WIP] add drag handle to drag and drop, add error handling and user feedback

This commit is contained in:
Sina Blattmann 2023-03-13 11:41:56 +01:00
parent 366e0af986
commit 3a6f4fc046
16 changed files with 249 additions and 87 deletions

View File

@ -26,6 +26,7 @@
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"formik": "^2.2.9", "formik": "^2.2.9",
"mobx-react-lite": "^3.4.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
@ -13218,6 +13219,37 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/mobx": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/mobx/-/mobx-6.8.0.tgz",
"integrity": "sha512-+o/DrHa4zykFMSKfS8Z+CPSEg5LW9tSNGTuN8o6MF1GKxlfkSHSeJn5UtgxvPkGgaouplnrLXCF+duAsmm6FHQ==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mobx"
}
},
"node_modules/mobx-react-lite": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz",
"integrity": "sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mobx"
},
"peerDependencies": {
"mobx": "^6.1.0",
"react": "^16.8.0 || ^17 || ^18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -28150,6 +28182,18 @@
"minimist": "^1.2.6" "minimist": "^1.2.6"
} }
}, },
"mobx": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/mobx/-/mobx-6.8.0.tgz",
"integrity": "sha512-+o/DrHa4zykFMSKfS8Z+CPSEg5LW9tSNGTuN8o6MF1GKxlfkSHSeJn5UtgxvPkGgaouplnrLXCF+duAsmm6FHQ==",
"peer": true
},
"mobx-react-lite": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz",
"integrity": "sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==",
"requires": {}
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View File

@ -21,6 +21,7 @@
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"formik": "^2.2.9", "formik": "^2.2.9",
"mobx-react-lite": "^3.4.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",

View File

@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Alert, Button, CircularProgress, Grid } from "@mui/material"; import { Alert, Button, CircularProgress, Grid } from "@mui/material";
import Container from "@mui/material/Container"; import Container from "@mui/material/Container";
import axiosConfig, { axiosConfigWithoutToken } from "./config/axiosConfig"; import { axiosConfigWithoutToken } from "./config/axiosConfig";
import InnovenergyTextfield from "./components/Layout/InnovenergyTextfield"; import InnovenergyTextfield from "./components/Layout/InnovenergyTextfield";
const loginUser = async (username: string, password: string) => { const loginUser = async (username: string, password: string) => {
@ -17,18 +17,20 @@ const Login = ({ setToken }: { setToken: (value: string) => void }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const verifyToken = async () => { const verifyToken = async (token: string) => {
axiosConfig.get("/GetAllInstallations"); axiosConfigWithoutToken.get("/GetAllInstallations", {
headers: { auth: token },
});
}; };
const handleSubmit = () => { const handleSubmit = () => {
setLoading(true); setLoading(true);
loginUser(username, password).then(({ data }) => { loginUser(username, password).then(({ data }) => {
// TODO change this if they return err codes from backend // TODO change this if they return err codes from backend
if (typeof data === "string") { if (data && data.token) {
verifyToken() verifyToken(data.token)
.then(() => { .then(() => {
setToken(data); setToken(data.token);
setLoading(false); setLoading(false);
}) })
.catch((err) => { .catch((err) => {

View File

@ -1,10 +1,11 @@
import { Box, CircularProgress, Alert } from "@mui/material"; import { Box, CircularProgress, Alert } from "@mui/material";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { useState, useEffect } from "react"; import { useState, useEffect, useContext } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import axiosConfig from "../../config/axiosConfig"; import axiosConfig from "../../config/axiosConfig";
import { I_Installation } from "../../util/types"; import { I_Installation } from "../../util/types";
import FolderForm from "./FolderForm"; import FolderForm from "./FolderForm";
import GroupDataContext from "./Tree/GroupDataContext";
const Folder = () => { const Folder = () => {
const { id } = useParams(); const { id } = useParams();

View File

@ -1,9 +1,10 @@
import { Alert, Button, Grid, Snackbar } from "@mui/material"; import { Button, CircularProgress, Grid } from "@mui/material";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import axiosConfig from "../../config/axiosConfig"; import axiosConfig from "../../config/axiosConfig";
import { I_Folder } from "../../util/types"; import { I_Folder } from "../../util/types";
import InnovenergySnackbar from "../InnovenergySnackbar";
import InnovenergyTextfield from "../Layout/InnovenergyTextfield"; import InnovenergyTextfield from "../Layout/InnovenergyTextfield";
interface I_CustomerFormProps { interface I_CustomerFormProps {
@ -19,6 +20,8 @@ const FolderForm = (props: I_CustomerFormProps) => {
const intl = useIntl(); const intl = useIntl();
const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarOpen, setSnackbarOpen] = useState(false);
const [error, setError] = useState();
const [loading, setLoading] = useState(false);
const formik = useFormik({ const formik = useFormik({
initialValues: { initialValues: {
@ -26,21 +29,25 @@ const FolderForm = (props: I_CustomerFormProps) => {
information: values.information, information: values.information,
}, },
onSubmit: (formikValues) => { onSubmit: (formikValues) => {
setLoading(true);
const idAsNumber = parseInt(id, 10); const idAsNumber = parseInt(id, 10);
updateFolder({ updateFolder({
...values, ...values,
...formikValues, ...formikValues,
id: idAsNumber, id: idAsNumber,
}).then((res) => { })
setSnackbarOpen(true); .then((res) => {
}); setSnackbarOpen(true);
setLoading(false);
})
.catch((err) => {
setSnackbarOpen(true);
setError(err);
setLoading(false);
});
}, },
}); });
const handleClose = () => {
setSnackbarOpen(false);
};
return ( return (
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<InnovenergyTextfield <InnovenergyTextfield
@ -64,26 +71,16 @@ const FolderForm = (props: I_CustomerFormProps) => {
handleChange={formik.handleChange} handleChange={formik.handleChange}
/> />
<Grid container justifyContent="flex-end" sx={{ pt: 1 }}> <Grid container justifyContent="flex-end" sx={{ pt: 1 }}>
<Button variant="outlined" type="submit"> {loading && <CircularProgress />}
<Button variant="outlined" type="submit" sx={{ height: 40, ml: 2 }}>
<FormattedMessage id="applyChanges" defaultMessage="Apply changes" /> <FormattedMessage id="applyChanges" defaultMessage="Apply changes" />
</Button> </Button>
</Grid> </Grid>
<Snackbar <InnovenergySnackbar
error={error}
setOpen={setSnackbarOpen}
open={snackbarOpen} open={snackbarOpen}
anchorOrigin={{ />
vertical: "top",
horizontal: "center",
}}
autoHideDuration={6000}
onClose={handleClose}
>
<Alert onClose={handleClose} severity="success" sx={{ width: "100%" }}>
<FormattedMessage
id="updatedSuccessfully"
defaultMessage="Updated successfully"
/>
</Alert>
</Snackbar>
</form> </form>
); );
}; };

View File

@ -17,45 +17,51 @@ const GroupTabs = () => {
const id = routeMatch?.params?.id; const id = routeMatch?.params?.id;
const intl = useIntl(); const intl = useIntl();
return ( if (id) {
<Box sx={{ width: "100%" }}> return (
<Box sx={{ borderBottom: 1, borderColor: "divider" }}> <Box sx={{ width: "100%" }}>
<Tabs value={routeMatch?.pattern?.path} aria-label="basic tabs example"> <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
{routeMatch?.pathname.includes("folder") ? ( <Tabs
<Tab value={routeMatch?.pattern?.path}
label={intl.formatMessage({ aria-label="basic tabs example"
id: "folder", >
defaultMessage: "Folder", {routeMatch?.pathname.includes("folder") ? (
})} <Tab
value={routes.groups + routes.folder + ":id"} label={intl.formatMessage({
component={Link} id: "folder",
to={routes.folder + id} defaultMessage: "Folder",
/> })}
) : ( value={routes.groups + routes.folder + ":id"}
<Tab component={Link}
label={intl.formatMessage({ to={routes.folder + id}
id: "installation", />
defaultMessage: "Installation", ) : (
})} <Tab
value={routes.groups + routes.installation + ":id"} label={intl.formatMessage({
component={Link} id: "installation",
to={routes.installation + id} defaultMessage: "Installation",
/> })}
)} value={routes.groups + routes.installation + ":id"}
component={Link}
to={routes.installation + id}
/>
)}
<Tab <Tab
label={intl.formatMessage({ label={intl.formatMessage({
id: "users", id: "users",
defaultMessage: "Users", defaultMessage: "Users",
})} })}
value={routes.groups + routes.users + ":id"} value={routes.groups + routes.users + ":id"}
component={Link} component={Link}
to={routes.users + id} to={routes.users + id}
/> />
</Tabs> </Tabs>
</Box>
</Box> </Box>
</Box> );
); }
return null;
}; };
export default GroupTabs; export default GroupTabs;

View File

@ -1,7 +1,9 @@
import { Grid } from "@mui/material"; import { Grid } from "@mui/material";
import { Container } from "@mui/system"; import { Container } from "@mui/system";
import { useState } from "react";
import { Routes, Route } from "react-router"; import { Routes, Route } from "react-router";
import routes from "../../routes.json"; import routes from "../../routes.json";
import { I_Folder, I_Installation } from "../../util/types";
import InstallationDetail from "../Installations/Installation"; import InstallationDetail from "../Installations/Installation";
import NavigationButtons from "../Layout/NavigationButtons"; import NavigationButtons from "../Layout/NavigationButtons";
import Folder from "./Folder"; import Folder from "./Folder";
@ -9,12 +11,14 @@ import GroupTabs from "./GroupTabs";
import GroupTree from "./Tree/GroupTree"; import GroupTree from "./Tree/GroupTree";
const Groups = () => { const Groups = () => {
const [data, setData] = useState<(I_Folder | I_Installation)[]>();
return ( return (
<Container maxWidth="xl"> <Container maxWidth="xl">
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={3}> <Grid item xs={3}>
<NavigationButtons /> <NavigationButtons />
<GroupTree /> <GroupTree data={data} setData={setData} />
</Grid> </Grid>
<Grid item xs={9}> <Grid item xs={9}>
<GroupTabs /> <GroupTabs />

View File

@ -1,7 +1,7 @@
import { DragLayerMonitorProps } from "@minoru/react-dnd-treeview"; import { DragLayerMonitorProps } from "@minoru/react-dnd-treeview";
import { I_Installation, I_Folder } from "../../../util/types"; import { I_Installation, I_Folder } from "../../../util/types";
import styles from "./DragPreview.module.scss"; import styles from "./DragPreview.module.scss";
import TypeIcon from "./TypeIcon"; import TypeIcon from "../TypeIcon";
interface DragPreviewProps { interface DragPreviewProps {
monitorProps: DragLayerMonitorProps<I_Installation | I_Folder>; monitorProps: DragLayerMonitorProps<I_Installation | I_Folder>;

View File

@ -0,0 +1,13 @@
import { createContext } from "react";
import { I_Folder, I_Installation } from "../../../util/types";
interface GroupData {
data: (I_Folder | I_Installation)[] | undefined;
setData: (value: (I_Folder | I_Installation)[]) => void;
}
const GroupDataContext = createContext<GroupData>({
setData: (value) => {},
data: [],
});
export default GroupDataContext;

View File

@ -20,6 +20,6 @@
} }
.treeContainer { .treeContainer {
height: 500px; max-height: 500px;
overflow: auto; overflow: auto;
} }

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import axiosConfig from "../../../config/axiosConfig"; import axiosConfig from "../../../config/axiosConfig";
import { I_Folder, I_Installation } from "../../../util/types"; import { I_Folder, I_Installation } from "../../../util/types";
import { CircularProgress, Grid } from "@mui/material"; import { Alert, CircularProgress, Grid } from "@mui/material";
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { import {
MultiBackend, MultiBackend,
@ -14,6 +14,7 @@ import TreeNode from "./TreeNode";
import styles from "./GroupTree.module.scss"; import styles from "./GroupTree.module.scss";
import withScrolling from "react-dnd-scrolling"; import withScrolling from "react-dnd-scrolling";
import DragPreview from "./DragPreview"; import DragPreview from "./DragPreview";
import InnovenergySnackbar from "../../InnovenergySnackbar";
const getTreeData = ( const getTreeData = (
data: (I_Folder | I_Installation)[] data: (I_Folder | I_Installation)[]
@ -30,21 +31,49 @@ const getTreeData = (
}); });
}; };
const GroupTree = () => { interface GroupTreeProps {
const [data, setData] = useState<(I_Folder | I_Installation)[]>(); data: (I_Folder | I_Installation)[] | undefined;
setData: (value: (I_Folder | I_Installation)[]) => void;
}
const GroupTree = (props: GroupTreeProps) => {
const { data, setData } = props;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [getError, setGetError] = useState(false);
const [putError, setPutError] = useState(false);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const ScrollingComponent = withScrolling("div"); const ScrollingComponent = withScrolling("div");
const getData = useCallback(async () => {
setLoading(true);
return axiosConfig
.get("/GetAllFoldersAndInstallations")
.then((res) => {
setData(res.data);
setLoading(false);
})
.catch((err) => {
setGetError(err);
setLoading(false);
});
}, [setData]);
useEffect(() => { useEffect(() => {
getData(); getData();
}, []); }, [getData]);
const getData = async () => { const findParent = (element: I_Folder | I_Installation): any => {
setLoading(true); if (data) {
return axiosConfig.get("/GetAllFoldersAndInstallations").then((res) => { const parent = data.find(
setData(res.data); (el) => el.type === "Folder" && el.id === element.parentId
setLoading(false); );
}); if (parent) {
return findParent(parent);
}
return element.parentId;
}
}; };
const handleDrop = ( const handleDrop = (
@ -62,7 +91,13 @@ const GroupTree = () => {
} }
) )
.then(() => { .then(() => {
setSnackbarOpen(true);
getData(); getData();
})
.catch((err) => {
setPutError(err);
setLoading(false);
setSnackbarOpen(true);
}); });
}; };
@ -107,8 +142,19 @@ const GroupTree = () => {
) => handleDrop(tree, options)} ) => handleDrop(tree, options)}
/> />
</ScrollingComponent> </ScrollingComponent>
<InnovenergySnackbar
error={putError}
open={snackbarOpen}
setOpen={setSnackbarOpen}
/>
</DndProvider> </DndProvider>
); );
} else if (getError) {
return (
<Alert severity="error" sx={{ mt: 1 }}>
Couldn't load data
</Alert>
);
} }
return null; return null;
}; };

View File

@ -6,7 +6,7 @@ import styles from "./TreeNode.module.scss";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import routes from "../../../routes.json"; import routes from "../../../routes.json";
import { I_Folder, I_Installation } from "../../../util/types"; import { I_Folder, I_Installation } from "../../../util/types";
import TypeIcon from "./TypeIcon"; import TypeIcon from "../TypeIcon";
import DragHandleIcon from "@mui/icons-material/DragHandle"; import DragHandleIcon from "@mui/icons-material/DragHandle";
type Props = { type Props = {
@ -52,6 +52,7 @@ const TreeNode: React.FC<Props> = (props) => {
textDecoration: "none", textDecoration: "none",
color: "black", color: "black",
}} }}
draggable={false}
> >
<div className={styles.labelGridItem}> <div className={styles.labelGridItem}>
<Typography variant="body2">{node.text}</Typography> <Typography variant="body2">{node.text}</Typography>

View File

@ -0,0 +1,46 @@
import { Alert, Snackbar } from "@mui/material";
import { FormattedMessage } from "react-intl";
interface InnovenergySnackbarProps {
open: boolean;
setOpen: (value: boolean) => void;
error?: any;
}
const InnovenergySnackbar = (props: InnovenergySnackbarProps) => {
const { open, setOpen, error } = props;
const handleClose = () => {
setOpen(false);
};
return (
<Snackbar
open={open}
anchorOrigin={{
vertical: "top",
horizontal: "center",
}}
autoHideDuration={6000}
onClose={handleClose}
>
<Alert
onClose={handleClose}
severity={error ? "error" : "success"}
sx={{ width: "100%" }}
>
{error ? (
<FormattedMessage
id="updateFolderErrorMessage"
defaultMessage="Couldn't update folder, an error occured"
/>
) : (
<FormattedMessage
id="updatedSuccessfully"
defaultMessage="Updated successfully"
/>
)}
</Alert>
</Snackbar>
);
};
export default InnovenergySnackbar;

View File

@ -16,6 +16,7 @@
"logout": "Logout", "logout": "Logout",
"updatedSuccessfully": "Erfolgreich aktualisiert", "updatedSuccessfully": "Erfolgreich aktualisiert",
"groups": "Gruppen", "groups": "Gruppen",
"group": "Gruppe", "group": "Gruppe",
"folder": "Ordner" "folder": "Ordner",
"updateFolderErrorMessage": "Der Ordner konnte nicht aktualisiert werden, ein Fehler ist aufgetreten"
} }

View File

@ -17,6 +17,6 @@
"updatedSuccessfully": "Updated successfully", "updatedSuccessfully": "Updated successfully",
"groups": "Groups", "groups": "Groups",
"group": "Group", "group": "Group",
"folder": "folder" "folder": "folder",
"updateFolderErrorMessage": "Couldn't update folder, an error occured"
} }