From e86e89f7ce86e541dc7cf465c8892344c25e4169 Mon Sep 17 00:00:00 2001 From: Dan6erbond Date: Wed, 6 Jan 2021 17:18:56 +0100 Subject: [PATCH] Create interactive Kanban with drag and drop functionality, as well as add task dialog for prototyping purposes of UX Using `react-dnd` with HTML5 drag and drop backend. --- client/src/App.tsx | 76 +++--- client/src/components/Kanban.tsx | 182 --------------- .../src/components/kanban/AddTaskDialog.tsx | 68 ++++++ client/src/components/kanban/Column.tsx | 112 +++++++++ client/src/components/kanban/DropZone.tsx | 32 +++ client/src/components/kanban/Kanban.tsx | 220 ++++++++++++++++++ client/src/components/kanban/Task.tsx | 98 ++++++++ client/src/pages/Project.tsx | 2 +- 8 files changed, 571 insertions(+), 219 deletions(-) delete mode 100644 client/src/components/Kanban.tsx create mode 100644 client/src/components/kanban/AddTaskDialog.tsx create mode 100644 client/src/components/kanban/Column.tsx create mode 100644 client/src/components/kanban/DropZone.tsx create mode 100644 client/src/components/kanban/Kanban.tsx create mode 100644 client/src/components/kanban/Task.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index ed36c91..79a006b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,8 @@ import { CssBaseline, Typography } from "@material-ui/core"; import { makeStyles, ThemeProvider } from "@material-ui/core/styles"; import { Assignment } from "@material-ui/icons"; import React from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; import "./App.css"; import Layout from "./Layout"; @@ -34,43 +36,45 @@ function App() { ); return ( - -
- - - - , - text: "Tasks", - url: "/tasks", - }, - ]} - > - - - -
Test.
-
- - - - Error! - -
- - 404: Not Found - -
-
-
+ + +
+ + + + , + text: "Tasks", + url: "/tasks", + }, + ]} + > + + + +
Test.
+
+ + + + Error! + +
+ + 404: Not Found + +
+
+
+
- -
-
+
+
+ ); } diff --git a/client/src/components/Kanban.tsx b/client/src/components/Kanban.tsx deleted file mode 100644 index 2fb8de8..0000000 --- a/client/src/components/Kanban.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { Avatar, Grid, makeStyles, Paper, Typography } from "@material-ui/core"; -import clsx from "clsx"; -import React from "react"; - -const useStyles = makeStyles((theme) => ({ - header: { - padding: theme.spacing(2, 1), - background: - theme.palette.type === "light" - ? theme.palette.background.default - : theme.palette.background.paper, - }, - title: { - ...theme.typography.subtitle1, - padding: theme.spacing(1), - textAlign: "center", - borderRadius: theme.palette.type === "light" ? "0px" : theme.shape.borderRadius, - }, - title_1: { - backgroundColor: theme.palette.type === "light" ? "#a8ff78" : "#134e5e", - background: - theme.palette.type === "light" - ? "linear-gradient(to right, #a8ff78, #78ffd6)" - : "linear-gradient(to right, #134e5e, #71b280)", - color: - theme.palette.type === "light" - ? theme.palette.getContrastText("#a8ff78") - : theme.palette.getContrastText("#134e5e"), - }, - title_2: { - backgroundColor: theme.palette.type === "light" ? "#f85032" : "#333333", - background: - theme.palette.type === "light" - ? "linear-gradient(to right, #f85032, #e73827)" - : "linear-gradient(to right, #333333, #dd1818)", - color: - theme.palette.type === "light" - ? theme.palette.getContrastText("#f85032") - : theme.palette.getContrastText("#333333"), - }, - title_3: { - backgroundColor: theme.palette.type === "light" ? "#2193b0" : "#373b44", - background: - theme.palette.type === "light" - ? "linear-gradient(to right, #2193b0, #6dd5ed)" - : "linear-gradient(to right, #373b44, #4286f4)", - color: - theme.palette.type === "light" - ? theme.palette.getContrastText("#2193b0") - : theme.palette.getContrastText("#373b44"), - }, - kanban: { - display: "flex", - background: - theme.palette.type === "light" - ? theme.palette.background.default - : theme.palette.background.paper, - flexDirection: "column", - padding: theme.spacing(0, 2), - boxShadow: theme.palette.type === "dark" ? theme.shadows[2] : "none", - }, - sections: { - flexBasis: 0, - flexGrow: 1, - }, - sectionContainer: { - flexBasis: 0, - flexGrow: 1, - display: "flex", - flexDirection: "column", - }, - section: { - flexBasis: 0, - flexGrow: 1, - display: "flex", - flexDirection: "column", - boxShadow: theme.palette.type === "light" ? theme.shadows[2] : "none", - borderRadius: theme.palette.type === "light" ? theme.shape.borderRadius : "0px", - paddingBottom: theme.spacing(2), - overflow: "hidden", - }, - member: { - flexBasis: 0, - flexGrow: 1, - overflowX: "auto", - background: theme.palette.background.paper, - }, - task: { - background: theme.palette.background.default, - padding: theme.spacing(2), - margin: theme.spacing(1), - borderRadius: - theme.palette.type === "light" ? theme.shape.borderRadius : "0px", - }, - purple: { - background: "#915AFE", - }, - blue: { - background: "#35CDFB", - }, - taskFooter: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - }, -})); - -interface KanbanProps { - className?: string; -} - -function Kanban({ className }: KanbanProps) { - const classes = useStyles(); - - const DummyTask = () => { - const rand = React.useMemo(() => Math.random(), []); - - return ( - - Title - - Description. - -
-
- 0.5 ? classes.purple : classes.blue}> - {rand > 0.5 ? "DB" : "RM"} - - - 01.01.2021 - -
-
- ); - }; - - return ( - - - Kanban - - - - - - To Do - - - - - - - - - - In Progress - - - - - - - - - - Done - - - - - - - - - - - - - ); -} - -export default Kanban; diff --git a/client/src/components/kanban/AddTaskDialog.tsx b/client/src/components/kanban/AddTaskDialog.tsx new file mode 100644 index 0000000..65e6ebf --- /dev/null +++ b/client/src/components/kanban/AddTaskDialog.tsx @@ -0,0 +1,68 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogProps, + DialogTitle, + TextField, +} from "@material-ui/core"; +import React from "react"; + +interface AddTaskDialogProps extends DialogProps { + handleClose: () => void; + handleAdd: (title: string, description: string) => void; + status: string; +} + +function AddTaskDialog({ + status, + handleClose, + handleAdd, + ...props +}: AddTaskDialogProps) { + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + + return ( + + + Add Task to {status}: + +
handleAdd(title, description)}> + + setTitle(e.target.value)} + /> + setDescription(e.target.value)} + /> + + + + + +
+
+ ); +} + +export default AddTaskDialog; diff --git a/client/src/components/kanban/Column.tsx b/client/src/components/kanban/Column.tsx new file mode 100644 index 0000000..f71dd64 --- /dev/null +++ b/client/src/components/kanban/Column.tsx @@ -0,0 +1,112 @@ +import { + Grid, + IconButton, + makeStyles, + Paper, + useMediaQuery, + useTheme, +} from "@material-ui/core"; +import { PlaylistAdd } from "@material-ui/icons"; +import clsx from "clsx"; +import React from "react"; +import AddTaskDialog from "./AddTaskDialog"; + +const useStyles = makeStyles((theme) => ({ + title: { + ...theme.typography.subtitle1, + padding: theme.spacing(1, 1, 1, 3), + textAlign: "center", + borderRadius: + theme.palette.type === "light" ? "0px" : theme.shape.borderRadius, + margin: theme.palette.type === "light" ? "0" : theme.spacing(0, 1), + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + sectionContainer: { + flexBasis: 0, + flexGrow: 1, + display: "flex", + flexDirection: "column", + }, + section: { + flexBasis: 0, + flexGrow: 1, + display: "flex", + flexDirection: "column", + boxShadow: theme.palette.type === "light" ? theme.shadows[2] : "none", + borderRadius: + theme.palette.type === "light" ? theme.shape.borderRadius : "0px", + paddingBottom: theme.spacing(2), + overflow: "hidden", + }, + member: { + flexBasis: 0, + flexGrow: 1, + overflowX: "auto", + background: theme.palette.background.paper, + display: "flex", + flexDirection: "column", + padding: theme.spacing(0, 1), + marginTop: theme.spacing(1), + }, +})); + +interface ColumnProps { + className?: string; + status: string; + children: React.ReactNode; + addTask: (status: string, title: string, description: string) => void; +} + +function Column({ className, status, children, addTask }: ColumnProps) { + const classes = useStyles(); + const [open, setOpen] = React.useState(false); + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleAdd = (title: string, description: string) => { + setOpen(false); + addTask(status, title, description); + }; + + return ( + <> + + + +

{status}

+ + + +
+ + {children} + +
+
+ + + ); +} + +export default Column; diff --git a/client/src/components/kanban/DropZone.tsx b/client/src/components/kanban/DropZone.tsx new file mode 100644 index 0000000..85eb625 --- /dev/null +++ b/client/src/components/kanban/DropZone.tsx @@ -0,0 +1,32 @@ +import { makeStyles } from "@material-ui/core"; +import React from "react"; +import { useDrop } from "react-dnd"; +import { TaskItem } from "./Task"; + +const useStyles = makeStyles((theme) => ({ + dropZone: { + flexGrow: 1, + }, +})); + +interface DropZoneProps { + status: string; + setTaskStatus: (id: number, status: string) => void; +} + +function DropZone({ status, setTaskStatus }: DropZoneProps) { + const classes = useStyles(); + + const ref = React.useRef(null); + const [, drop] = useDrop({ + accept: "card", + drop(item) { + setTaskStatus(item.id, status); + }, + }); + drop(ref); + + return
; +} + +export default DropZone; diff --git a/client/src/components/kanban/Kanban.tsx b/client/src/components/kanban/Kanban.tsx new file mode 100644 index 0000000..34bb02e --- /dev/null +++ b/client/src/components/kanban/Kanban.tsx @@ -0,0 +1,220 @@ +import { Grid, makeStyles, Paper, Typography } from "@material-ui/core"; +import clsx from "clsx"; +import React from "react"; +import Column from "./Column"; +import DropZone from "./DropZone"; +import Task from "./Task"; + +const statuses = ["To Do", "In Progress", "Done"]; + +const defaultTasks = Array.from({ length: 9 }).map((_, index) => { + const rand = Math.random(); + const status = statuses[Math.floor(Math.random() * statuses.length)]; + + return { + status, + index, + id: index, + title: "Title", + description: "Description", + date: "01.01.2020", + author: rand > 0.5 ? "DM" : "RM", + authorColor: rand > 0.5 ? "#915AFE" : "#35CDFB", + }; +}); + +const useStyles = makeStyles((theme) => ({ + header: { + padding: theme.spacing(2, 1), + background: + theme.palette.type === "light" + ? theme.palette.background.default + : theme.palette.background.paper, + }, + title_1: { + backgroundColor: theme.palette.type === "light" ? "#a8ff78" : "#134e5e", + background: + theme.palette.type === "light" + ? "linear-gradient(to right, #a8ff78, #78ffd6)" + : "linear-gradient(to right, #134e5e, #71b280)", + color: + theme.palette.type === "light" + ? theme.palette.getContrastText("#a8ff78") + : theme.palette.getContrastText("#134e5e"), + }, + title_2: { + backgroundColor: theme.palette.type === "light" ? "#f85032" : "#333333", + background: + theme.palette.type === "light" + ? "linear-gradient(to right, #f85032, #e73827)" + : "linear-gradient(to right, #333333, #dd1818)", + color: + theme.palette.type === "light" + ? theme.palette.getContrastText("#f85032") + : theme.palette.getContrastText("#333333"), + }, + title_3: { + backgroundColor: theme.palette.type === "light" ? "#2193b0" : "#373b44", + background: + theme.palette.type === "light" + ? "linear-gradient(to right, #2193b0, #6dd5ed)" + : "linear-gradient(to right, #373b44, #4286f4)", + color: + theme.palette.type === "light" + ? theme.palette.getContrastText("#2193b0") + : theme.palette.getContrastText("#373b44"), + }, + kanban: { + display: "flex", + background: + theme.palette.type === "light" + ? theme.palette.background.default + : theme.palette.background.paper, + flexDirection: "column", + padding: theme.spacing(0, 2), + boxShadow: theme.palette.type === "dark" ? theme.shadows[2] : "none", + }, + sections: { + flexBasis: 0, + flexGrow: 1, + }, + section: { + flexBasis: 0, + flexGrow: 1, + display: "flex", + flexDirection: "column", + boxShadow: theme.palette.type === "light" ? theme.shadows[2] : "none", + borderRadius: + theme.palette.type === "light" ? theme.shape.borderRadius : "0px", + paddingBottom: theme.spacing(2), + overflow: "hidden", + }, + member: { + flexBasis: 0, + flexGrow: 1, + overflowX: "auto", + background: theme.palette.background.paper, + }, +})); + +interface KanbanProps { + className?: string; +} + +function Kanban({ className }: KanbanProps) { + const classes = useStyles(); + + const [tasks, setTasks] = React.useState(defaultTasks); + + const setTaskStatus = React.useCallback( + (id: number, status: string) => { + setTasks((tasks) => + tasks.map((task) => { + if (task.id === id) { + const index = + Math.max( + -1, + ...tasks + .filter((task) => task.status === status) + .map((t) => t.index) + ) + 1; + return { ...task, status, index }; + } + return task; + }) + ); + }, + [setTasks] + ); + + const moveTask = React.useCallback( + (id: number, status: string, index: number) => { + setTasks((tasks) => { + const task = tasks.find((t) => t.id === id); + const oldIndex = task!.index; + + return tasks.map((task) => { + if (task.id === id) { + return { ...task, status, index }; + } else if (task.status === status && oldIndex > index) { + if (task.index >= index) { + return { ...task, index: task.index + 1 }; + } + } else { + if (task.index === index) { + return { ...task, index: oldIndex }; + } + } + return task; + }); + }); + }, + [setTasks] + ); + + const statusClasses = React.useMemo( + () => [classes.title_1, classes.title_2, classes.title_3], + [classes] + ); + + const addTask = React.useCallback( + (status: string, title: string, description: string) => { + setTasks((tasks) => { + const rand = Math.random(); + const id = Math.max(-1, ...tasks.map((task) => task.id)) + 1; + const index = + Math.max( + -1, + ...tasks + .filter((task) => task.status === status) + .map((task) => task.index) + ) + 1; + + return [ + ...tasks, + { + id, + index, + status, + title, + description, + date: new Date().toLocaleDateString(), + author: rand > 0.5 ? "DM" : "RM", + authorColor: rand > 0.5 ? "#915AFE" : "#35CDFB", + }, + ]; + }); + }, + [setTasks] + ); + + return ( + + + Kanban + + + {statuses.map((status, index) => ( + + <> + {tasks + .filter((task) => task.status === status) + .sort((task1, task2) => (task1.index < task2.index ? -1 : 1)) + .map((task) => ( + + ))} + + + + ))} + + + ); +} + +export default Kanban; diff --git a/client/src/components/kanban/Task.tsx b/client/src/components/kanban/Task.tsx new file mode 100644 index 0000000..e9dcd24 --- /dev/null +++ b/client/src/components/kanban/Task.tsx @@ -0,0 +1,98 @@ +import { + Avatar, + makeStyles, + Paper, + Theme, + Typography, +} from "@material-ui/core"; +import React from "react"; +import { useDrag, useDrop } from "react-dnd"; + +interface StyleProps { + authorColor: string; + isDragging: boolean; +} + +const useStyles = makeStyles((theme) => ({ + task: { + background: theme.palette.background.default, + padding: theme.spacing(2), + marginBottom: theme.spacing(1), + borderRadius: + theme.palette.type === "light" ? theme.shape.borderRadius : "0px", + opacity: (props) => (props.isDragging ? 0 : 1), + cursor: "move", + }, + avatar: { + background: (props) => props.authorColor, + }, + taskFooter: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }, +})); + +export interface ITask { + id: number; + title: string; + description: string; + date: string; + author: string; + authorColor: string; + status: string; + index: number; +} + +export interface TaskItem extends ITask { + type: "card"; +} + +interface TaskProps { + moveTask: (id: number, status: string, index: number) => void; +} + +function Task({ moveTask, ...task }: TaskProps & ITask) { + const ref = React.useRef(null); + const [, drop] = useDrop({ + accept: "card", + hover(item: TaskItem) { + if (!ref.current) { + return; + } + + if (item.id !== task.id) { + moveTask(item.id, task.status, task.index); + } + }, + }); + + const [{ isDragging }, drag] = useDrag({ + item: { ...task, type: "card" }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + drag(drop(ref)); + + const classes = useStyles({ authorColor: task.authorColor, isDragging }); + + return ( + + {task.title} + + {task.description} + +
+
+ {task.author} + + {task.date} + +
+
+ ); +} + +export default Task; diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index 4bfb3bd..4ff5149 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -7,7 +7,7 @@ import { } from "@material-ui/core"; import clsx from "clsx"; import React from "react"; -import Kanban from "../components/Kanban"; +import Kanban from "../components/kanban/Kanban"; const useStyles = makeStyles((theme) => ({ sidebar: {