Merge backend + frontend #1
@@ -79,5 +79,5 @@
 | 
			
		||||
  "prettier": {
 | 
			
		||||
    "trailingComma": "all"
 | 
			
		||||
  },
 | 
			
		||||
  "proxy": "http://localhost:5050"
 | 
			
		||||
  "proxy": "http://127.0.0.1:5050"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 = () => (
 | 
			
		||||
  <BrowserRouter>
 | 
			
		||||
    <ScrollToTop />
 | 
			
		||||
@@ -28,6 +33,30 @@ const Router = () => (
 | 
			
		||||
      />
 | 
			
		||||
      <Route path="/forgotten-password/" element={<ForgottenPassword />} />
 | 
			
		||||
      <Route path="/reset-password/:token/" element={<ResetPassword />} />
 | 
			
		||||
      <Route
 | 
			
		||||
        path="/"
 | 
			
		||||
        element={
 | 
			
		||||
          <RequireAuth>
 | 
			
		||||
            <Todos />
 | 
			
		||||
          </RequireAuth>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <Route
 | 
			
		||||
        path="/todos/new/"
 | 
			
		||||
        element={
 | 
			
		||||
          <RequireAuth>
 | 
			
		||||
            <CreateTodo />
 | 
			
		||||
          </RequireAuth>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <Route
 | 
			
		||||
        path="/todos/:id/"
 | 
			
		||||
        element={
 | 
			
		||||
          <RequireAuth>
 | 
			
		||||
            <EditTodo />
 | 
			
		||||
          </RequireAuth>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </Routes>
 | 
			
		||||
  </BrowserRouter>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										61
									
								
								frontend/src/components/Todo.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								frontend/src/components/Todo.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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 = <Skeleton width="200px" />;
 | 
			
		||||
  } else if (todo.due !== null) {
 | 
			
		||||
    secondary = format(todo.due, "P");
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <ListItem
 | 
			
		||||
      secondaryAction={
 | 
			
		||||
        <IconButton
 | 
			
		||||
          disabled={todo === undefined}
 | 
			
		||||
          edge="end"
 | 
			
		||||
          onClick={() => deleteTodo(todo?.id!)}
 | 
			
		||||
        >
 | 
			
		||||
          <DeleteIcon />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <ListItemButton
 | 
			
		||||
        component={Link}
 | 
			
		||||
        disabled={todo === undefined}
 | 
			
		||||
        to={`/todos/${todo?.id}/`}
 | 
			
		||||
      >
 | 
			
		||||
        <ListItemIcon>
 | 
			
		||||
          <Checkbox
 | 
			
		||||
            checked={todo?.complete ?? false}
 | 
			
		||||
            disabled
 | 
			
		||||
            disableRipple
 | 
			
		||||
            edge="start"
 | 
			
		||||
            tabIndex={-1}
 | 
			
		||||
          />
 | 
			
		||||
        </ListItemIcon>
 | 
			
		||||
        <ListItemText
 | 
			
		||||
          primary={todo?.task ?? <Skeleton />}
 | 
			
		||||
          secondary={secondary}
 | 
			
		||||
        />
 | 
			
		||||
      </ListItemButton>
 | 
			
		||||
    </ListItem>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
export default Todo;
 | 
			
		||||
							
								
								
									
										44
									
								
								frontend/src/components/TodoForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/components/TodoForm.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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<any>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const validationSchema = yup.object({
 | 
			
		||||
  complete: yup.boolean(),
 | 
			
		||||
  due: yup.date().nullable(),
 | 
			
		||||
  task: yup.string().required("Required"),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const TodoForm = ({ initialValues, label, onSubmit }: IProps) => (
 | 
			
		||||
  <Formik<ITodoData>
 | 
			
		||||
    initialValues={initialValues}
 | 
			
		||||
    onSubmit={onSubmit}
 | 
			
		||||
    validationSchema={validationSchema}
 | 
			
		||||
  >
 | 
			
		||||
    {({ dirty, isSubmitting }) => (
 | 
			
		||||
      <Form>
 | 
			
		||||
        <TextField fullWidth label="Task" name="task" required />
 | 
			
		||||
        <DateField fullWidth label="Due" name="due" />
 | 
			
		||||
        <CheckboxField fullWidth label="Complete" name="complete" />
 | 
			
		||||
        <FormActions
 | 
			
		||||
          disabled={!dirty}
 | 
			
		||||
          isSubmitting={isSubmitting}
 | 
			
		||||
          label={label}
 | 
			
		||||
          links={[{ label: "Back", to: "/" }]}
 | 
			
		||||
        />
 | 
			
		||||
      </Form>
 | 
			
		||||
    )}
 | 
			
		||||
  </Formik>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export default TodoForm;
 | 
			
		||||
							
								
								
									
										36
									
								
								frontend/src/pages/CreateTodo.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								frontend/src/pages/CreateTodo.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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 (
 | 
			
		||||
    <>
 | 
			
		||||
      <Title title="Create a Todo" />
 | 
			
		||||
      <TodoForm
 | 
			
		||||
        initialValues={{ complete: false, due: null, task: "" }}
 | 
			
		||||
        label="Create"
 | 
			
		||||
        onSubmit={onSubmit}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default CreateTodo;
 | 
			
		||||
							
								
								
									
										52
									
								
								frontend/src/pages/EditTodo.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								frontend/src/pages/EditTodo.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
							
								
								
									
										38
									
								
								frontend/src/pages/Todos.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/pages/Todos.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
							
								
								
									
										72
									
								
								frontend/src/queries.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								frontend/src/queries.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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"]),
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
		Reference in New Issue
	
	Block a user