diff --git a/frontend/package.json b/frontend/package.json index 4c674c1..8970741 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -79,5 +79,5 @@ "prettier": { "trailingComma": "all" }, - "proxy": "http://localhost:5050" + "proxy": "http://127.0.0.1:5050" } diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 9d01e4b..5222208 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import ScrollToTop from "./components/ScrollToTop"; import TopBar from "./components/TopBar"; + import Register from "./pages/Register"; import ConfirmEmail from "./pages/ConfirmEmail"; import Login from "./pages/Login"; @@ -10,6 +11,10 @@ import ChangePassword from "./pages/ChangePassword"; import ForgottenPassword from "./pages/ForgottenPassword"; import ResetPassword from "./pages/ResetPassword"; +import CreateTodo from "./pages/CreateTodo"; +import EditTodo from "./pages/EditTodo"; +import Todos from "./pages/Todos"; + const Router = () => ( @@ -28,6 +33,30 @@ const Router = () => ( /> } /> } /> + + + + } + /> + + + + } + /> + + + + } + /> ); diff --git a/frontend/src/components/Todo.tsx b/frontend/src/components/Todo.tsx new file mode 100644 index 0000000..6ccf550 --- /dev/null +++ b/frontend/src/components/Todo.tsx @@ -0,0 +1,61 @@ +import Checkbox from "@mui/material/Checkbox"; +import IconButton from "@mui/material/IconButton"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Skeleton from "@mui/material/Skeleton"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { format } from "date-fns"; +import { Link } from "react-router-dom"; + +import { Todo as TodoModel } from "../models"; +import { useDeleteTodoMutation } from "../queries"; + +interface IProps { + todo?: TodoModel; +} + +const Todo = ({ todo }: IProps) => { + const { mutateAsync: deleteTodo } = useDeleteTodoMutation(); + let secondary; + if (todo === undefined) { + secondary = ; + } else if (todo.due !== null) { + secondary = format(todo.due, "P"); + } + return ( + deleteTodo(todo?.id!)} + > + + + } + > + + + + + } + secondary={secondary} + /> + + + ); +}; +export default Todo; diff --git a/frontend/src/components/TodoForm.tsx b/frontend/src/components/TodoForm.tsx new file mode 100644 index 0000000..e3fb837 --- /dev/null +++ b/frontend/src/components/TodoForm.tsx @@ -0,0 +1,44 @@ +import { Form, Formik } from "formik"; +import * as yup from "yup"; + +import CheckboxField from "../components/CheckboxField"; +import DateField from "../components/DateField"; +import FormActions from "../components/FormActions"; +import TextField from "../components/TextField"; +import type { ITodoData } from "../queries"; + +interface IProps { + initialValues: ITodoData; + label: string; + onSubmit: (data: ITodoData) => Promise; +} + +const validationSchema = yup.object({ + complete: yup.boolean(), + due: yup.date().nullable(), + task: yup.string().required("Required"), +}); + +const TodoForm = ({ initialValues, label, onSubmit }: IProps) => ( + + initialValues={initialValues} + onSubmit={onSubmit} + validationSchema={validationSchema} + > + {({ dirty, isSubmitting }) => ( +
+ + + + + + )} + +); + +export default TodoForm; diff --git a/frontend/src/pages/CreateTodo.tsx b/frontend/src/pages/CreateTodo.tsx new file mode 100644 index 0000000..acfb4ad --- /dev/null +++ b/frontend/src/pages/CreateTodo.tsx @@ -0,0 +1,36 @@ +import { useContext } from "react"; +import { useNavigate } from "react-router-dom"; + +import TodoForm from "../components/TodoForm"; +import Title from "../components/Title"; +import type { ITodoData } from "../queries"; +import { useCreateTodoMutation } from "../queries"; +import { ToastContext } from "../ToastContext"; + +const CreateTodo = () => { + const navigate = useNavigate(); + const { addToast } = useContext(ToastContext); + const { mutateAsync: createTodo } = useCreateTodoMutation(); + + const onSubmit = async (data: ITodoData) => { + try { + await createTodo(data); + navigate("/"); + } catch { + addToast("Try Again", "error"); + } + }; + + return ( + <> + + <TodoForm + initialValues={{ complete: false, due: null, task: "" }} + label="Create" + onSubmit={onSubmit} + /> + </> + ); +}; + +export default CreateTodo; diff --git a/frontend/src/pages/EditTodo.tsx b/frontend/src/pages/EditTodo.tsx new file mode 100644 index 0000000..3369d11 --- /dev/null +++ b/frontend/src/pages/EditTodo.tsx @@ -0,0 +1,52 @@ +import Skeleton from "@mui/material/Skeleton"; +import { useContext } from "react"; +import { useNavigate, useParams } from "react-router"; + +import TodoForm from "../components/TodoForm"; +import Title from "../components/Title"; +import type { ITodoData } from "../queries"; +import { useEditTodoMutation, useTodoQuery } from "../queries"; +import { ToastContext } from "../ToastContext"; + +interface Iparams { + id: string; +} + +const EditTodo = () => { + const navigate = useNavigate(); + const params = useParams<keyof Iparams>() as Iparams; + const todoId = parseInt(params.id, 10); + const { addToast } = useContext(ToastContext); + const { data: todo } = useTodoQuery(todoId); + const { mutateAsync: editTodo } = useEditTodoMutation(todoId); + + const onSubmit = async (data: ITodoData) => { + try { + await editTodo(data); + navigate("/"); + } catch { + addToast("Try again", "error"); + } + }; + + return ( + <> + <Title title="Edit todo" /> + {todo === undefined ? ( + <Skeleton height="80px" /> + ) : ( + <TodoForm + initialValues={{ + complete: todo.complete, + due: todo.due, + task: todo.task, + }} + label="Edit" + onSubmit={onSubmit} + /> + )} + </> + ); +}; + +export default EditTodo; diff --git a/frontend/src/pages/Todos.tsx b/frontend/src/pages/Todos.tsx new file mode 100644 index 0000000..55b4935 --- /dev/null +++ b/frontend/src/pages/Todos.tsx @@ -0,0 +1,38 @@ +import Fab from "@mui/material/Fab"; +import List from "@mui/material/List"; +import AddIcon from "@mui/icons-material/Add"; +import { Link, Navigate } from "react-router-dom"; + +import Todo from "../components/Todo"; +import { useTodosQuery } from "../queries"; + +const Todos = () => { + const { data: todos } = useTodosQuery(); + + if (todos?.length === 0) { + return <Navigate to="/todos/new/" />; + } else { + return ( + <> + <List> + {todos !== undefined + ? todos.map((todo) => <Todo key={todo.id} todo={todo} />) + : [1, 2, 3].map((id) => <Todo key={-id} />)} + </List> + <Fab + component={Link} + sx={{ + bottom: (theme) => theme.spacing(2), + position: "fixed", + right: (theme) => theme.spacing(2), + }} + to="/todos/new/" + > + <AddIcon /> + </Fab> + </> + ); + } +}; + +export default Todos; diff --git a/frontend/src/queries.ts b/frontend/src/queries.ts new file mode 100644 index 0000000..c7f451a --- /dev/null +++ b/frontend/src/queries.ts @@ -0,0 +1,72 @@ +import axios from "axios"; +import { useQueryClient } from "@tanstack/react-query"; + +import { Todo } from "./models"; +import { useMutation, useQuery } from "./query"; + +export const STALE_TIME = 1000 * 60 * 5; // 5 mins + +export const useTodosQuery = () => + useQuery<Todo[]>( + ["todos"], + async () => { + const response = await axios.get("/todos/"); + return response.data.todos.map((json: any) => new Todo(json)); + }, + { staleTime: STALE_TIME }, + ); + +export const useTodoQuery = (id: number) => { + const queryClient = useQueryClient(); + return useQuery<Todo>( + ["todos", id.toString()], + async () => { + const response = await axios.get(`/todos/${id}/`); + return new Todo(response.data); + }, + { + initialData: () => { + return queryClient + .getQueryData<Todo[]>(["todos"]) + ?.filter((todo: Todo) => todo.id === id)[0]; + }, + staleTime: STALE_TIME, + }, + ); +}; + +export interface ITodoData { + complete: boolean; + due: Date | null; + task: string; +} + +export const useCreateTodoMutation = () => { + const queryClient = useQueryClient(); + return useMutation( + async (data: ITodoData) => await axios.post("/todos/", data), + { + onSuccess: () => queryClient.invalidateQueries(["todos"]), + }, + ); +}; + +export const useEditTodoMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation( + async (data: ITodoData) => await axios.put(`/todos/${id}/`, data), + { + onSuccess: () => queryClient.invalidateQueries(["todos"]), + }, + ); +}; + +export const useDeleteTodoMutation = () => { + const queryClient = useQueryClient(); + return useMutation( + async (id: number) => await axios.delete(`/todos/${id}/`), + { + onSuccess: () => queryClient.invalidateQueries(["todos"]), + }, + ); +};