[WIP] change tree structure from dnd

This commit is contained in:
Sina Blattmann 2023-03-20 11:16:08 +01:00
parent 9c0ada86e1
commit c4e7ce54c0
13 changed files with 173 additions and 445 deletions

View File

@ -1,63 +1,47 @@
import { NodeModel } from "@minoru/react-dnd-treeview";
import { createContext, ReactNode, useCallback, useState } from "react"; import { createContext, ReactNode, useCallback, useState } from "react";
import axiosConfig from "../../config/axiosConfig"; import axiosConfig from "../../config/axiosConfig";
import { transformArrayToTree } from "../../util/group.util";
import { I_Folder, I_Installation } from "../../util/types"; import { I_Folder, I_Installation } from "../../util/types";
interface GroupContextProviderProps { interface GroupContextProviderProps {
currentType: string;
setCurrentType: (value: string) => void;
isMove: boolean;
setIsMove: (value: boolean) => void;
data: (I_Folder | I_Installation)[]; data: (I_Folder | I_Installation)[];
setData: (value: (I_Folder | I_Installation)[]) => void; setData: (value: (I_Folder | I_Installation)[]) => void;
fetchData: () => Promise<void>; fetchData: () => Promise<void>;
loading: boolean; loading: boolean;
setLoading: (value: boolean) => void; setLoading: (value: boolean) => void;
getError: boolean; getError: boolean;
tree: NodeModel<I_Folder | I_Installation>[];
setTree: (value: NodeModel<I_Folder | I_Installation>[]) => void;
currentType: string;
setCurrentType: (value: string) => void;
} }
export const GroupContext = createContext<GroupContextProviderProps>({ export const GroupContext = createContext<GroupContextProviderProps>({
currentType: "",
setCurrentType: () => {},
isMove: false,
setIsMove: () => {},
data: [], data: [],
setData: () => {}, setData: () => {},
fetchData: () => Promise.resolve(), fetchData: () => Promise.resolve(),
loading: false, loading: false,
setLoading: () => {}, setLoading: () => {},
getError: false, getError: false,
tree: [],
setTree: () => {},
currentType: "",
setCurrentType: () => {},
}); });
const getTreeData = (
data: (I_Folder | I_Installation)[]
): NodeModel<I_Folder | I_Installation>[] => {
return data.map((element) => {
const isFolder = element.type === "Folder";
return {
id: isFolder ? element.id : "installation-" + element.id,
parent: element.parentId,
text: element.name,
droppable: isFolder,
data: element,
};
});
};
const GroupContextProvider = ({ children }: { children: ReactNode }) => { const GroupContextProvider = ({ children }: { children: ReactNode }) => {
const [currentType, setCurrentType] = useState("");
const [isMove, setIsMove] = useState(false);
const [data, setData] = useState<(I_Folder | I_Installation)[]>([]); const [data, setData] = useState<(I_Folder | I_Installation)[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [getError, setGetError] = useState(false); const [getError, setGetError] = useState(false);
const [tree, setTree] = useState<NodeModel<I_Folder | I_Installation>[]>([]);
const [currentType, setCurrentType] = useState("");
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
return axiosConfig return axiosConfig
.get("/GetAllFoldersAndInstallations") .get("/GetAllFoldersAndInstallations")
.then((res) => { .then((res) => {
setData(res.data); setData(transformArrayToTree(res.data));
setTree(getTreeData(res.data));
setLoading(false); setLoading(false);
}) })
.catch((err) => { .catch((err) => {
@ -69,16 +53,16 @@ const GroupContextProvider = ({ children }: { children: ReactNode }) => {
return ( return (
<GroupContext.Provider <GroupContext.Provider
value={{ value={{
currentType,
setCurrentType,
isMove,
setIsMove,
data, data,
setData, setData,
fetchData, fetchData,
loading, loading,
setLoading, setLoading,
getError, getError,
tree,
setTree,
currentType,
setCurrentType,
}} }}
> >
{children} {children}

View File

@ -1,4 +1,4 @@
import { Button, CircularProgress, Grid } from "@mui/material"; import { Button, CircularProgress, Grid, InputLabel } from "@mui/material";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@ -7,6 +7,7 @@ import { I_Folder } from "../../util/types";
import { GroupContext } from "../Context/GroupContextProvider"; import { GroupContext } from "../Context/GroupContextProvider";
import InnovenergySnackbar from "../InnovenergySnackbar"; import InnovenergySnackbar from "../InnovenergySnackbar";
import InnovenergyTextfield from "../Layout/InnovenergyTextfield"; import InnovenergyTextfield from "../Layout/InnovenergyTextfield";
import MoveTree from "./Tree/MoveTree";
interface I_CustomerFormProps { interface I_CustomerFormProps {
values: I_Folder; values: I_Folder;
@ -20,23 +21,12 @@ const updateFolder = (data: I_Folder) => {
const FolderForm = (props: I_CustomerFormProps) => { const FolderForm = (props: I_CustomerFormProps) => {
const { values, id } = props; const { values, id } = props;
const intl = useIntl(); const intl = useIntl();
const { isMove, setIsMove } = useContext(GroupContext);
const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarOpen, setSnackbarOpen] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedParentId, setSelectedParentId] = useState<number>();
const { setTree, tree } = useContext(GroupContext);
const updateTree = (newValues: I_Folder) => {
const elementToUpdate = tree.find(
(element) => element.data?.id.toString() === id
);
if (elementToUpdate) {
elementToUpdate.data = newValues;
elementToUpdate.text = newValues.name;
setTree([...tree]);
}
};
const formik = useFormik({ const formik = useFormik({
initialValues: { initialValues: {
@ -49,10 +39,10 @@ const FolderForm = (props: I_CustomerFormProps) => {
updateFolder({ updateFolder({
...values, ...values,
...formikValues, ...formikValues,
parentId: selectedParentId ?? values.parentId,
id: idAsNumber, id: idAsNumber,
}) })
.then((res) => { .then((res) => {
updateTree({ ...values, ...formikValues, id: idAsNumber });
setSnackbarOpen(true); setSnackbarOpen(true);
setLoading(false); setLoading(false);
}) })
@ -86,6 +76,23 @@ const FolderForm = (props: I_CustomerFormProps) => {
value={formik.values.information} value={formik.values.information}
handleChange={formik.handleChange} handleChange={formik.handleChange}
/> />
<Grid container direction="row" alignItems="center" spacing={2}>
<Grid item xs={3}>
<InputLabel>
<FormattedMessage id="location" defaultMessage="Location" />
</InputLabel>
</Grid>
<Grid item xs={9} display="inline">
<Button
variant="outlined"
sx={{ height: 40, ml: 2 }}
onClick={() => setIsMove(true)}
>
<FormattedMessage id="move" defaultMessage="Move" />
</Button>
{isMove && <MoveTree setSelectedParentId={setSelectedParentId} />}
</Grid>
</Grid>
<Grid container justifyContent="flex-end" sx={{ pt: 1 }}> <Grid container justifyContent="flex-end" sx={{ pt: 1 }}>
{loading && <CircularProgress />} {loading && <CircularProgress />}
<Button variant="outlined" type="submit" sx={{ height: 40, ml: 2 }}> <Button variant="outlined" type="submit" sx={{ height: 40, ml: 2 }}>

View File

@ -3,11 +3,11 @@ import { Container } from "@mui/system";
import { Routes, Route } from "react-router"; import { Routes, Route } from "react-router";
import routes from "../../routes.json"; import routes from "../../routes.json";
import Installation from "../Installations/Installation"; import Installation from "../Installations/Installation";
import NavigationButtons from "../Layout/NavigationButtons";
import Folder from "./Folder"; import Folder from "./Folder";
import GroupTabs from "./GroupTabs"; import GroupTabs from "./GroupTabs";
import GroupContextProvider from "../Context/GroupContextProvider"; import GroupContextProvider from "../Context/GroupContextProvider";
import GroupTree from "./Tree/UserTree"; import GroupTree from "./Tree/GroupTree";
import NavigationButtons from "../Layout/NavigationButtons";
const Groups = () => { const Groups = () => {
return ( return (
@ -15,6 +15,7 @@ const Groups = () => {
<Container maxWidth="xl"> <Container maxWidth="xl">
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={3}> <Grid item xs={3}>
<NavigationButtons />
<GroupTree /> <GroupTree />
</Grid> </Grid>
<Grid item xs={9}> <Grid item xs={9}>

View File

@ -1,20 +0,0 @@
.root {
align-items: "center";
background-color: #1967d2;
border-radius: 4px;
box-shadow: 0 12px 24px -6px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(0, 0, 0, 0.08);
color: #fff;
display: inline-grid;
font-size: 14px;
gap: 8px;
grid-template-columns: auto auto;
padding: 4px 8px;
pointer-events: none;
}
.icon,
.label {
align-items: center;
display: flex;
}

View File

@ -1,23 +0,0 @@
import { DragLayerMonitorProps } from "@minoru/react-dnd-treeview";
import { I_Installation, I_Folder } from "../../../util/types";
import styles from "./DragPreview.module.scss";
import TypeIcon from "../TypeIcon";
interface DragPreviewProps {
monitorProps: DragLayerMonitorProps<I_Installation | I_Folder>;
}
const DragPreview = (props: DragPreviewProps) => {
const item = props.monitorProps.item;
return (
<div className={styles.root}>
<div className={styles.icon}>
<TypeIcon type={item.data?.type} />
</div>
<div className={styles.label}>{item.text}</div>
</div>
);
};
export default DragPreview;

View File

@ -1,25 +0,0 @@
.tree {
list-style-type: none;
padding-left: 0;
}
.app {
height: 100%;
}
.treeRoot {
height: 100%;
}
.draggingSource {
opacity: 0.3;
}
.dropTarget {
background-color: #e8f0fe;
}
.treeContainer {
max-height: 500px;
overflow: auto;
}

View File

@ -1,117 +1,66 @@
import { useEffect, useState, useContext } from "react"; import TreeView from "@mui/lab/TreeView";
import axiosConfig from "../../../config/axiosConfig"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { ReactNode, useContext, useEffect } from "react";
import { TreeItem } from "@mui/lab";
import { I_Folder, I_Installation } from "../../../util/types"; import { I_Folder, I_Installation } from "../../../util/types";
import { Alert, CircularProgress, Grid } from "@mui/material"; import { Link } from "react-router-dom";
import { DndProvider } from "react-dnd"; import routes from "../../../routes.json";
import {
MultiBackend,
getBackendOptions,
Tree,
NodeModel,
DropOptions,
} from "@minoru/react-dnd-treeview";
import TreeNode from "./TreeNode";
import styles from "./GroupTree.module.scss";
import withScrolling from "react-dnd-scrolling";
import DragPreview from "./DragPreview";
import InnovenergySnackbar from "../../InnovenergySnackbar";
import { GroupContext } from "../../Context/GroupContextProvider"; import { GroupContext } from "../../Context/GroupContextProvider";
import { instanceOfFolder } from "../../../util/group.util";
const GroupTree = () => { const GroupTree = () => {
const { data, fetchData, loading, setLoading, getError, setTree, tree } = const { setCurrentType, fetchData, data } = useContext(GroupContext);
useContext(GroupContext);
const [putError, setPutError] = useState(false);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [openNodes, setOpenNodes] = useState<(string | number)[]>([]);
const ScrollingComponent = withScrolling("div");
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
const handleDrop = ( const getNodes = (element: I_Folder | I_Installation): null | ReactNode => {
newTree: NodeModel<I_Folder | I_Installation>[], if (instanceOfFolder(element)) {
{ dropTargetId, dragSource }: DropOptions<I_Folder | I_Installation> return element.children ? renderTree(element.children) : null;
) => { }
axiosConfig return null;
.put(
dragSource?.data?.type === "Folder"
? "/UpdateFolder"
: "/UpdateInstallation",
{
...dragSource?.data,
parentId: dropTargetId,
}
)
.then(() => {
setSnackbarOpen(true);
setTree(newTree);
})
.catch((err) => {
setPutError(err);
setLoading(false);
setSnackbarOpen(true);
});
}; };
if (loading) { const renderTree = (data: (I_Folder | I_Installation)[]): ReactNode => {
return data.map((element) => {
return (
<Link
key={element.id}
to={
element.type === "Folder"
? routes.folder + element.id
: routes.installation + element.id
}
style={{
textDecoration: "none",
color: "black",
}}
draggable={false}
>
<TreeItem
key={element.id}
nodeId={element.id.toString()}
label={element.name}
onClick={() => setCurrentType(element.type)}
>
{getNodes(element)}
</TreeItem>
</Link>
);
});
};
if (data) {
return ( return (
<Grid container justifyContent="center" width="100%"> <TreeView
<CircularProgress sx={{ m: 6 }} /> aria-label="rich object"
</Grid> defaultCollapseIcon={<ExpandMoreIcon />}
); defaultExpandIcon={<ChevronRightIcon />}
} else if (data && data?.length > 1) { sx={{ height: 300, flexGrow: 1, maxWidth: 400 }}
return ( >
<DndProvider backend={MultiBackend} options={getBackendOptions()}> {renderTree(data)}
<ScrollingComponent className={styles.treeContainer}> </TreeView>
<Tree<I_Installation | I_Folder>
tree={tree}
rootId={0}
dragPreviewRender={(monitorProps) => (
<DragPreview monitorProps={monitorProps} />
)}
classes={{
container: styles.tree,
root: styles.treeRoot,
draggingSource: styles.draggingSource,
dropTarget: styles.dropTarget,
}}
render={(
node: NodeModel<I_Installation | I_Folder>,
{ depth, isOpen, onToggle, hasChild, handleRef }
) => (
<TreeNode
node={node}
depth={depth}
isOpen={isOpen}
onToggle={onToggle}
hasChild={hasChild}
handleRef={handleRef}
/>
)}
onDrop={(
tree: NodeModel<I_Folder | I_Installation>[],
options: DropOptions<I_Folder | I_Installation>
) => handleDrop(tree, options)}
onChangeOpen={(nodeIds: (number | string)[]) =>
setOpenNodes(nodeIds)
}
initialOpen={openNodes}
/>
</ScrollingComponent>
<InnovenergySnackbar
error={putError}
open={snackbarOpen}
setOpen={setSnackbarOpen}
/>
</DndProvider>
);
} else if (getError) {
return (
<Alert severity="error" sx={{ mt: 1 }}>
Couldn't load data
</Alert>
); );
} }
return null; return null;

View File

@ -0,0 +1,58 @@
import TreeView from "@mui/lab/TreeView";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { ReactNode, useContext } from "react";
import { TreeItem } from "@mui/lab";
import { I_Folder, I_Installation } from "../../../util/types";
import { GroupContext } from "../../Context/GroupContextProvider";
import { instanceOfFolder } from "../../../util/group.util";
interface GroupTreeProps {
setSelectedParentId: (value: number) => void;
}
const GroupTree = (props: GroupTreeProps) => {
const { data } = useContext(GroupContext);
const getNodes = (element: I_Folder | I_Installation): null | ReactNode => {
if (instanceOfFolder(element)) {
return element.children ? renderTree(element.children) : null;
}
return null;
};
const renderTree = (data: (I_Folder | I_Installation)[]): ReactNode => {
return data
.filter((element) => element.type === "Folder")
.map((element) => {
return (
<TreeItem
key={"move-tree-element-" + element.id}
nodeId={element.id.toString()}
label={element.name}
>
{getNodes(element)}
</TreeItem>
);
});
};
if (data) {
return (
<TreeView
aria-label="rich object"
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
sx={{ height: 200, flexGrow: 1, width: 400, overflow: "auto" }}
onNodeSelect={(e: any, id: string) =>
props.setSelectedParentId(parseInt(id))
}
>
{renderTree(data)}
</TreeView>
);
}
return null;
};
export default GroupTree;

View File

@ -1,40 +0,0 @@
.root {
align-items: center;
display: grid;
grid-template-columns: auto auto 1fr auto;
height: 32px;
padding-inline-end: 8px;
}
.expandIconWrapper {
align-items: center;
font-size: 0;
cursor: pointer;
display: flex;
height: 24px;
justify-content: center;
width: 24px;
transition: transform linear 0.1s;
transform: rotate(0deg);
}
.expandIconWrapper.isOpen {
transform: rotate(90deg);
}
.labelGridItem {
padding-inline-start: 8px;
}
.handle {
cursor: grab;
display: flex;
}
.handle > svg {
pointer-events: none;
}
.selected {
background-color: #f5910014;
}

View File

@ -1,95 +0,0 @@
import React, { useContext, useEffect } from "react";
import Typography from "@mui/material/Typography";
import ArrowRightIcon from "@mui/icons-material/ArrowRight";
import { NodeModel } from "@minoru/react-dnd-treeview";
import styles from "./TreeNode.module.scss";
import { Link } from "react-router-dom";
import routes from "../../../routes.json";
import { I_Folder, I_Installation } from "../../../util/types";
import TypeIcon from "../TypeIcon";
import DragHandleIcon from "@mui/icons-material/DragHandle";
import useRouteMatch from "../../../hooks/useRouteMatch";
import { GroupContext } from "../../Context/GroupContextProvider";
interface TreeNodeProps {
node: NodeModel<I_Installation | I_Folder>;
depth: number;
isOpen: boolean;
onToggle: (id: NodeModel["id"]) => void;
hasChild: boolean;
handleRef: React.RefObject<any>;
}
const TreeNode = (props: TreeNodeProps) => {
const { node, isOpen, hasChild, onToggle, depth, handleRef } = props;
const indent = depth * 24;
const { setCurrentType } = useContext(GroupContext);
const routeMatch = useRouteMatch([
routes.groups + routes.installation + ":id",
routes.groups + routes.folder + ":id",
routes.groups + routes.users + ":id",
]);
const isSelected = routeMatch?.params.id === node.data?.id.toString();
const handleToggle = (e: React.MouseEvent) => {
setTimeout(
() =>
document
.getElementById(node.id.toString())
?.scrollIntoView({ block: "center" }),
0
);
e.stopPropagation();
onToggle(node.id);
};
useEffect(() => {
if (node.data && isSelected) {
setCurrentType(node.data?.type);
}
}, [isSelected, node.data, setCurrentType]);
return (
<div
id={node.id.toString()}
className={`tree-node ${styles.root} ${
isSelected ? styles.selected : ""
}`}
style={{ paddingInlineStart: indent }}
>
<div
className={`${styles.expandIconWrapper} ${isOpen ? styles.isOpen : ""}`}
>
{node.droppable && hasChild && (
<div onClick={handleToggle}>
<ArrowRightIcon />
</div>
)}
</div>
<TypeIcon type={node.data?.type} />
<Link
to={
node.droppable
? routes.folder + node.id
: routes.installation + (node.data ? node.data.id : "")
}
style={{
textDecoration: "none",
color: "black",
}}
draggable={false}
>
<div className={styles.labelGridItem}>
<Typography variant="body2">{node.text}</Typography>
</div>
</Link>
<div className={`${styles.handle} drag-handle`} ref={handleRef}>
<DragHandleIcon />
</div>
</div>
);
};
export default TreeNode;

View File

@ -1,75 +0,0 @@
import TreeView from "@mui/lab/TreeView";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { ReactNode, useEffect, useState } from "react";
import { TreeItem } from "@mui/lab";
import axiosConfig from "../../../config/axiosConfig";
import { I_Folder, I_Installation } from "../../../util/types";
const GroupTree = () => {
const [data, setData] = useState<(I_Folder | I_Installation)[]>();
useEffect(() => {
axiosConfig.get("/GetAllFoldersAndInstallations").then((res) => {
setData(res.data);
});
}, []);
const handleClick = (e: any, nodes: any) => {
console.log(e);
console.log(nodes);
};
const instanceOfFolder = (object: any): object is I_Folder => {
return "children" in object;
};
const getNodes = (element: I_Folder | I_Installation): null | ReactNode => {
if (instanceOfFolder(element)) {
return element.children ? renderTree(element.children) : null;
}
return null;
};
const nest = (
items: (I_Folder | I_Installation)[],
id: number | null = null
): any => {
return items.map((item) => {
if (item.parentId === 0 && item.type === "Installlation") {
return item;
}
return { ...item, children: nest(items, item.id) };
});
};
const renderTree = (data: (I_Folder | I_Installation)[]): ReactNode => {
return data.map((element) => {
return (
<TreeItem
key={element.id}
nodeId={element.id.toString()}
label={element.name}
>
{getNodes(element)}
</TreeItem>
);
});
};
return (
<></>
// <TreeView
// aria-label="rich object"
// defaultCollapseIcon={<ExpandMoreIcon />}
// defaultExpandIcon={<ChevronRightIcon />}
// sx={{ height: 300, flexGrow: 1, maxWidth: 400 }}
// onNodeToggle={handleClick}
// >
// {renderTree(data)}
// {data && renderTree(data)}
// </TreeView>
);
};
export default GroupTree;

View File

@ -1,14 +1,10 @@
import { Alert, Button, Grid, Snackbar } from "@mui/material"; import { Alert, Button, Grid, Snackbar } from "@mui/material";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { useContext, 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 useRouteMatch from "../../hooks/useRouteMatch";
import { I_Installation } from "../../util/types"; import { I_Installation } from "../../util/types";
import { GroupContext } from "../Context/GroupContextProvider";
import InnovenergyTextfield from "../Layout/InnovenergyTextfield"; import InnovenergyTextfield from "../Layout/InnovenergyTextfield";
import routes from "../../routes.json";
import { InstallationContext } from "../Context/InstallationContextProvider";
interface I_CustomerFormProps { interface I_CustomerFormProps {
values: I_Installation; values: I_Installation;
@ -19,12 +15,6 @@ const CustomerForm = (props: I_CustomerFormProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const intl = useIntl(); const intl = useIntl();
const { fetchData: fetchGroupData } = useContext(GroupContext);
const { fetchData: fetchInstallationData } = useContext(InstallationContext);
const groupsMatch = useRouteMatch([
routes.groups + routes.installation + ":id",
]);
const formik = useFormik({ const formik = useFormik({
initialValues: { initialValues: {
@ -42,11 +32,6 @@ const CustomerForm = (props: I_CustomerFormProps) => {
}) })
.then(() => { .then(() => {
setOpen(true); setOpen(true);
if (groupsMatch) {
fetchGroupData();
} else {
fetchInstallationData();
}
}); });
}, },
}); });

View File

@ -0,0 +1,22 @@
import { I_Folder, I_Installation } from "./types";
export const transformArrayToTree = (
array: (I_Folder | I_Installation)[],
parentId: number = 0
): (I_Folder | I_Installation)[] => {
return array
.filter((item) => item.parentId === parentId)
.map((item) => {
if (item.type === "Installation") {
return item;
} else {
const folder = item as I_Folder;
folder.children = transformArrayToTree(array, folder.id);
return folder;
}
});
};
export const instanceOfFolder = (object: any): object is I_Folder => {
return "children" in object;
};