Building a TODO App from Scratch — Step 5 — Extra Functionality

We left off in a decent place, but there was some missing functionality that we wanted all along.

  1. We need to be able to view completed to-dos (and be able to delete them entirely)
  2. We need to be able edit to-dos.

Article Series

Editing To-Dos

The interaction we decided on for editing is to double-click the todo. This will turn the to-do, in place, into an editable input. Then you hit enter (e.g. submit) or leave the input (e.g. blur) and it will save the changes.

We can use a bit of event delegation to set up this event:

list.addEventListener("dblclick", (event) => {
  const listItem = event.target.closest("li");

  // If already editing, let it be.
  if (listItem.classList.contains("editing")) return;

  listItem.classList.add("editing");

  // Do editing.
});Code language: JavaScript (javascript)

Now anywhere you double-click on the list will set the relevant list item into “editing mode”, here indicated by just adding a class we can style against, like hide the existing text.

More importantly, we need to insert some new HTML turning the text into an editable input. We can use a template literal of a <form> to inject as needed, like so:

list.addEventListener("dblclick", (event) => {
  const listItem = event.target.closest("li");

  // If already editing, let it be.
  if (listItem.classList.contains("editing")) return;

  listItem.classList.add("editing");
  const textItem = listItem.querySelector(".text");
  listItem.insertAdjacentHTML(
    "beforeend",
    `<form onsubmit="updateTodo(event);" class="form-edit">
       <input onblur="updateTodo(event);" type="text" class="input-edit" value="${textItem.textContent}">
     </form>`
  );
});
Code language: JavaScript (javascript)

That calls an updateTodo() event we’ll have to write. But first, let’s make sure we focus the input and put the cursor at the end. Just a bit of nice UX right?

list.addEventListener("dblclick", (event) => {
  const listItem = event.target.closest("li");

  // If already editing, let it be.
  if (listItem.classList.contains("editing")) return;

  listItem.classList.add("editing");
  const textItem = listItem.querySelector(".text");
  listItem.insertAdjacentHTML(
    "beforeend",
    `<form onsubmit="updateTodo(event);" class="form-edit"><input onblur="updateTodo(event);" type="text" class="input-edit" value="${textItem.textContent}"></form>`
  );

  const input = listItem.querySelector(".input-edit");
  input.focus();

  // put cursor at end of input
  input.setSelectionRange(input.value.length, input.value.length);
});
Code language: JavaScript (javascript)

Updating the to-do is pretty straightforward. We get our hands on the new text, update it in the DOM, toggle the editing class, and write the data back to localStorage. It looks like a lot of lines, but a lot of it is just getting our hands on DOM elements and basic manipulation.

function updateTodo(event) {
  event.preventDefault();
  const listItem = event.target.closest("li");
  const textItem = listItem.querySelector(".text");
  const inputItem = listItem.querySelector(".input-edit");
  const form = listItem.querySelector(".form-edit");
  textItem.textContent = inputItem.value;
  listItem.classList.remove("editing");
  form.remove();
  TODOs = TODOs.map((todo) => {
    if (todo.id === listItem.id) {
      todo.title = inputItem.value;
    }
    return todo;
  });
  localStorage["data"] = JSON.stringify(TODOs);
}Code language: JavaScript (javascript)

And it works!

Viewing Completed To-Dos

Before this, we could delete a to-do, but that was it. Even though our data structure was set up to have a complete attribute that could change, all we did was filter out the completed ones entirely from the data.

Here’s what that data structure is like:

{
  title: `text of to-do`,
  complete: false,
  id: self.crypto.randomUUID()
}Code language: JavaScript (javascript)

Now, when we check that checkbox in the UI to complete a to-do, we need to:

  1. Set complete to true if the list item is in the active list
  2. Remove the list item entirely if the to-do is already in the completed list

We’ll update our function to be called toggleTodo and do it like this:

function toggleTodo(event) {
  const listItem = event.target.parentElement;
  // Trigger complete animation
  listItem.classList.toggle("complete");
  setTimeout(() => {
    // list item is already complete, remove it
    if (listItem.dataset.complete === "true") {
      TODOs = TODOs.filter((todo) => !todo.complete);
    } else {
      // list item is just being set to complete now
      TODOs.forEach((todo) => {
        if (todo.id === listItem.id) {
          todo.complete = !todo.complete;
        }
      });
    }

    localStorage["data"] = JSON.stringify(TODOs);

    if (!document.startViewTransition) {
      buildUI();
    } else {
      document.startViewTransition(() => {
        buildUI();
      });
    }
  }, 1000);
}Code language: JavaScript (javascript)

This isn’t a big change from last time, just one little fork in the logic that either removes it or updates the data.

Now we need a control though to decide if we’re looking at the active to-dos or the completed ones. Let’s make that control in HTML:

<div class="todo-type-toggles">
  <button aria-pressed="true">Active</button>
  <button>Completed</button>
</div>Code language: HTML, XML (xml)

Now when you press the buttons, we’ll swap the state and re-build the UI accordingly:

const toggles = document.querySelectorAll(".todo-type-toggles > button");

toggles.forEach((toggle) => {
  toggle.addEventListener("click", (event) => {
    toggles.forEach((toggle) => {
      toggle.setAttribute("aria-pressed", false);
    });
    toggle.setAttribute("aria-pressed", true);

    if (toggle.textContent === states.ACTIVE) {
      buildUI(states.ACTIVE);
    } else {
      buildUI(states.COMPLETED);
    }
  });
});Code language: JavaScript (javascript)

Now I’m calling buildUI() with a param to declare which type I want to see. I like using little ENUM type variables for this just to make sure we don’t do typos.

const states = {
  ACTIVE: "Active",
  COMPLETED: "Completed"
};Code language: JavaScript (javascript)

Then we update the function to display one or the other…

function buildUI(state) {
  let HTML = ``;
  let viewTODOs = [];

  if (state === states.COMPLETED) {
    viewTODOs = TODOs.filter((todo) => todo.complete);
  } else {
    viewTODOs = TODOs.filter((todo) => !todo.complete);
  }

  if (viewTODOs.length === 0) {
    HTML = `<li class="empty">Nothing to do!</li>`;
  }

  // Loop over the viewTODOs and build HTML to insert, exactly as before.
}Code language: JavaScript (javascript)

This gives us an empty state as well.

And we’ve done it!

Perhaps more back-endy readers will be like “is this dude not sanitizing data before it goes to data storage?” and that would be a smart observation. We should probably be sanitizing the HTML. But for now, the only person you can pwn with this is yourself, so not a massive deal.

Here’s where we’ve gotten now:

Article Series

It's time to take your JavaScript to the next level

Leave a Reply

Your email address will not be published. Required fields are marked *

Did you know?

Frontend Masters Donates to open source projects. $313,806 contributed to date.