Create and edit todos
We will need to provide pages for users to create a new todo and edit
any existing ones. These pages both require a form that describes a
todo, which can be the same for both. This reduces the code we need to
write and removes duplication. This todo-form component will need
props for its initial values, a label for the submit button, and a
callable to be called on submission. The following should be added to
frontend/src/components/TodoForm.tsx
,
import { Form, Formik } from "formik";
import React from "react";
import { useTranslation } from "react-i18next";
import * as yup from "yup";
import CheckboxField from "src/components/CheckboxField";
import DateField from "src/components/DateField";
import SecondaryButton from "src/components/SecondaryButton";
import SubmitButton from "src/components/SubmitButton";
import TextField from "src/components/TextField";
export interface IForm {
complete: boolean;
due: Date | null;
task: string;
}
interface IProps {
initialValues: IForm;
label: string;
onSubmit: (data: IForm) => Promise<any>;
}
const TodoForm = ({ initialValues, label, onSubmit }: IProps) => {
const { t } = useTranslation();
const validationSchema = yup.object({
complete: yup.boolean(),
due: yup.date().nullable(),
task: yup.string().required(t("generic.required")),
});
return (
<Formik<IForm>
initialValues={initialValues}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ isSubmitting }) => (
<Form>
<TextField
fullWidth={true}
label={t("todo.task")}
name="task"
required={true}
/>
<DateField fullWidth={true} label={t("todo.due")} name="due" />
<CheckboxField
fullWidth={true}
label={t("todo.complete")}
name="complete"
/>
<SubmitButton label={label} submitting={isSubmitting} />
<SecondaryButton label={t("generic.back")} to="/todos/" />
</Form>
)}
</Formik>
);
};
export default TodoForm;
Creating a todo
We can then use the TodoForm in a page to create a todo by adding the
following to frontend/src/pages/CreateTodo.tsx
,
import Box from "@material-ui/core/Box";
import Typography from "@material-ui/core/Typography";
import axios from "axios";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "react-query";
import { useHistory } from "react-router";
import TodoForm, { IForm } from "src/components/TodoForm";
import { Todo } from "src/models";
import { useMutation } from "src/query";
import { ToastContext } from "src/ToastContext";
const CreateTodo = () => {
const { t } = useTranslation();
const history = useHistory();
const { addToast } = useContext(ToastContext);
const queryClient = useQueryClient();
const { mutateAsync: createTodo } = useMutation(
async (data: IForm) => {
const response = await axios.post("/todos/", data);
return new Todo(response.data);
},
{
onSuccess: async (todo: Todo) => {
queryClient.setQueryData(["todos", todo.id.toString()], todo);
if (queryClient.getQueryState("todos") !== undefined) {
queryClient.setQueryData("todos", (data: Todo[] | undefined) =>
data!.map((datum) => (datum.id === todo.id ? todo : datum)),
);
}
},
},
);
const onSubmit = async (data: IForm) => {
try {
await createTodo(data);
history.push("/");
} catch {
addToast({ category: "error", message: t("generic.tryAgainError") });
}
};
return (
<>
<Box mb={1} mt={2}>
<Typography component="h1" variant="h5">
{t("CreateTodo.lead")}
</Typography>
</Box>
<TodoForm
initialValues={{ complete: false, due: null, task: "" }}
label={t("CreateTodo.create")}
onSubmit={onSubmit}
/>
</>
);
};
export default CreateTodo;
Note
I like to use an additional onSubmit
function that calls the
mutation (createTodo
) function with the latter handling any
changes to the query (queryClient
) state and the former handling
everything else.
Editing a todo
We can also use the TodoForm to allow editing of a todo - as identified by its ID in the path. Much like with the Todos page we can improve the user experience by showing a skeleton whilst we wait for the backend to respond.
The following should be added to frontend/src/pages/EditTodo.tsx
,
import Box from "@material-ui/core/Box";
import Typography from "@material-ui/core/Typography";
import Skeleton from "@material-ui/lab/Skeleton";
import axios from "axios";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "react-query";
import { useHistory, useParams } from "react-router";
import TodoForm, { IForm } from "src/components/TodoForm";
import { Todo } from "src/models";
import { useMutation, useQuery } from "src/query";
import { ToastContext } from "src/ToastContext";
interface IParams {
id: string;
}
const EditTodo = () => {
const { t } = useTranslation();
const history = useHistory();
const params = useParams<IParams>();
const todoId = parseInt(params.id, 10);
const { addToast } = useContext(ToastContext);
const queryClient = useQueryClient();
const { data: todo } = useQuery<Todo>(
["todos", todoId.toString()],
async () => {
const response = await axios.get(`/todos/${todoId}/`);
return new Todo(response.data);
},
{
initialData: () => {
return queryClient
.getQueryData<Todo[]>("todos")
?.filter((todo: Todo) => todo.id === todoId)[0];
},
},
);
const { mutateAsync: editTodo } = useMutation(
async (data: IForm) => {
const response = await axios.put(`/todos/${todoId}/`, data);
return new Todo(response.data);
},
{
onSuccess: async (todo: Todo) => {
queryClient.setQueryData(["todos", todo.id.toString()], todo);
if (queryClient.getQueryState("todos") !== undefined) {
queryClient.setQueryData("todos", (data: Todo[] | undefined) =>
data!.map((datum) => (datum.id === todo.id ? todo : datum)),
);
}
history.push("/");
},
},
);
const onSubmit = async (data: IForm) => {
try {
await editTodo(data);
history.push("/");
} catch {
addToast({ category: "error", message: t("generic.tryAgainError") });
}
};
return (
<>
<Box mb={1} mt={2}>
<Typography component="h1" variant="h5">
{t("EditTodo.lead")}
</Typography>
</Box>
{todo === undefined ? (
<>
<Skeleton height="80px" />
<Skeleton height="80px" />
<Skeleton height="80px" width="200px" />
<Skeleton height="80px" width="200px" />
</>
) : (
<TodoForm
initialValues={{
complete: todo.complete,
due: todo.due ?? null,
task: todo.task,
}}
label={t("EditTodo.edit")}
onSubmit={onSubmit}
/>
)}
</>
);
};
export default EditTodo;
Routing changes
Both pages need to be added to the routing to be reachable, by adding
the following to frontend/src/Router.tsx
,
import PrivateRoute from "src/components/PrivateRoute";
import Todos from "src/pages/Todos";
...
const Router = () => (
<BrowserRouter>
...
<PrivateRoute exact={true} path="/todos/new/">
<CreateTodo />
</PrivateRoute>
<PrivateRoute exact={true} path="/todos/:id(\d+)/">
<EditTodo />
</PrivateRoute>
</BrowserRouter>
);