Compare commits
3 Commits
056401bb22
...
141eac7d17
Author | SHA1 | Date | |
---|---|---|---|
141eac7d17 | |||
5e72b2ebc9 | |||
719889e2bc |
@ -7,4 +7,7 @@ blueprint = Blueprint("control", __name__)
|
||||
@blueprint.get("/control/ping/")
|
||||
@rate_exempt
|
||||
async def ping() -> ResponseReturnValue:
|
||||
"""Ping the server
|
||||
Check if server is up and running.
|
||||
"""
|
||||
return {"ping": "pong"}
|
||||
|
@ -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"]),
|
||||
},
|
||||
);
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user