Skip to content

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>
);