DEV Community

Cover image for How to Build a Phoenix LiveView Todo List App with Testing.
Waitire Colline
Waitire Colline

Posted on

How to Build a Phoenix LiveView Todo List App with Testing.

Welcome to this tutorial on how to create a todo list with Phoenix LiveView!

Phoenix LiveView is a powerful framework that allows you to build interactive, real-time web applications that are fast without sacrificing reliability using Elixir and the Phoenix web framework.

In this tutorial, we'll build a simple to-do list application that demonstrates the power and simplicity of Phoenix LiveView. We will be adding tests too 😍 along the way.
 
This simple app allows users to add, mark as complete, and delete tasks in real-time. It has filters that display all, completed, and active tasks as shown in the gif below.

A gif showing a completed todo app we are building in this tutorial

Application Structure.

We will have one LiveView(todos_live.ex) and two LiveComponents(form_component.ex and todos_list_component.ex) with their corresponding templates in separate files as seen in the two images below. I used this approach to mimic how real apps are normally structured.

LiveComponents are a mechanism to compartmentalize state, markup, and events in LiveView. You can read more about them from the docs.

app structure

File structure

Prerequisites.

This tutorial assumes the following dependencies are installed on your machine; Erlang, Elixir, Phoenix, Node.js, and Postgres.

Elixir is the language we'll be using, Erlang is the language it's built on, Phoenix is the web framework, Node supports the system JavaScript that LiveView uses, and PostgreSQL is the database our app will use.

Create a new Phoenix liveview app.

In your terminal create a new Phoenix app with the following command.

mix phx.new todo_live_view
Enter fullscreen mode Exit fullscreen mode

todo_live_view will be the app name. Feel free to use any name. Select Y when you see the following prompt Fetch and install dependencies? [Yn] to download and install dependencies.

In your terminal, cd into the newly created app folder and configure the database with the following command mix ecto.create. If you see the following warning:the :gettext compiler is nolonger required in your mix.exs., follow the instructions provided in the terminal to remove :gettext entry and everything will be fine.

Run generated tests for generated code with mix test and then run the app with mix phx.server. The app should be accessible at localhost:4000.

Finished in 0.1 seconds (0.09s async, 0.09s sync)
3 tests, 0 failures
Enter fullscreen mode Exit fullscreen mode

default phoenix web app

Create Todos context and a Todo schema.

To save our todo items in the database we need to have a table and a schema. We will generate them along with a context, a migration file, and tests with the help of a Phoenix generator. Run the following command:

mix phx.gen.context Todos Todo todos text:string completed:boolean
Enter fullscreen mode Exit fullscreen mode

The first argument is the context module followed by the schema module and its plural name (used as the schema table name). The Todos context will serve as an API boundary for the Todo resource. You can find more about mix phx.gen.context from the docs.

Migrate your database with the following command:

mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

Bootstrap the pages.

Let us create a bare minimum of the files mentioned in the Application Structure section.

lib/todo_live_view_web/live/todos_live.ex

defmodule TodoLiveViewWeb.TodosLive do
  use TodoLiveViewWeb, :live_view
  alias TodoLiveView.Todos
  alias TodoLiveView.Todos.Todo

  def mount(_params, _session, socket) do
    {:ok,
     assign(socket, items: Todos.list_todos())
     |> assign_todo()}
  end

  def assign_todo(socket) do
    socket
    |> assign(:todo, %Todo{})
  end
end
Enter fullscreen mode Exit fullscreen mode

A LiveView is a simple module that requires two callbacks: mount/3 and render/1. If the render function is not defined, a template with the same name should be provided and ending in .html.heex.

Since we split code into LiveComponents, We will be using liveview as our source of truth. More can be found here.

lib/todo_live_view_web/live/todos_live.html.heex

<div>
  <h1>Todos</h1>
  <.live_component
    module={TodoLiveViewWeb.FormComponent}
    id="todo_input"
    text_value={nil}
    todo={@todo}
  />
  <.live_component
    module={TodoLiveViewWeb.TodosListComponent}
    items={@items}
    id="todos_list"
  />
</div>
Enter fullscreen mode Exit fullscreen mode

When calling the live component with .live_component, you must always pass the module and id attributes. The id will be available as an assign and it must be used to uniquely identify the component.

lib/todo_live_view_web/live/todos_list_component.ex

defmodule TodoLiveViewWeb.TodosListComponent do
  use TodoLiveViewWeb, :live_component
end
Enter fullscreen mode Exit fullscreen mode

The smallest LiveComponent only needs to define a render/1 function. In our case, we have a separate template file instead of a render function.

lib/todo_live_view_web/live/todos_list_component.html.heex

<div>
  <ul>
    <%= for item <- @items do %>
      <li id={"item-#{item.id}"}>
        <%= checkbox(
          :item,
          :completed,
          value: item.completed
        ) %>
        <p
          class="todo_item"
        >
          <%= item.text %>
        </p>
        <button
          class="delete_btn"
        >
          Delete
        </button>
      </li>
    <% end %>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

lib/todo_live_view_web/live/form_component.ex

defmodule TodoLiveViewWeb.FormComponent do
  use TodoLiveViewWeb, :live_component
  alias TodoLiveView.Todos

  @impl true
  def update(%{todo: todo} = assigns, socket) do
    changeset = Todos.change_todo(todo)

    {:ok,
     socket
     |> assign(assigns)
     |> assign(:changeset, changeset)}
  end
end
Enter fullscreen mode Exit fullscreen mode

We will be using a changeset in the form. More details can be found in the docs.

lib/todo_live_view_web/live/form_component.html.heex

<div>
  <.form
    let={f}
    for={@changeset}
    id="todo-form"
  >
    <%= label(f, :text) %>
    <%= text_input(f, :text) %>
    <%= error_tag(f, :text) %>

    <%= submit("Add todo", phx_disable_with: "Adding...") %>
  </.form>
</div>
Enter fullscreen mode Exit fullscreen mode

The error_tag/2 Phoenix view helper function displays the form's errors for a given field on a changeset. You can find more on forms from here.

lib/todo_live_view_web/router.ex

live "/", TodosLive
Enter fullscreen mode Exit fullscreen mode

We need to point to our liveview by changing get "/", PageController, :index to the above line.

Our app should now look like the following:

Tutorial app after bootstrapping pages
A single test should be falling when we run mix test. Delete test/todo_blog_web/controllers/page_controller_test.exs since we are no longer using PageController and all tests should pass.

Enable todo item creation.

Update lib/todo_live_view_web/live/form_component.ex with the following code:

.
.
.
alias TodoLiveView.Todos.Todo 
@todos_topic "todos"
.
.
.

  @impl true
  def handle_event("validate", %{"todo" => todo_params}, socket) do
    changeset =
      socket.assigns.todo
      |> Todos.change_todo(todo_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :changeset, changeset)}
  end

  @impl true
  def handle_event("create_todo", %{"todo" => todo_params}, socket) do
    save_todo(socket, todo_params)
  end

  defp save_todo(socket, todo_params) do
    case Todos.create_todo(todo_params) do
      {:ok, _todo} ->
        socket =
          assign(socket, items: Todos.list_todos())
          |> assign(:changeset, Todos.change_todo(%Todo{}))

        TodoLiveViewWeb.Endpoint.broadcast(@todos_topic, "todos_updated", socket.assigns)

        {:noreply, socket}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
Enter fullscreen mode Exit fullscreen mode

When handling the validate event, we use Map.put(:action, :validate) to add the validate action to the changeset, a signal that instructs Phoenix to display errors.

Since todos liveview is our source of truth, we need to inform it about the update of items after saving a todo item. We are broadcasting the update with Endpoint.broadcast and we have the advantage of getting distributed updates out of the box. An alternative is to have the component send a message directly to the parent view.

Update lib/todo_live_view_web/live/form_component.html.heex to the following:

<div>
  <.form
    let={f}
    for={@changeset}
    id="todo-form"
    phx-target={@myself}
    phx-change="validate"
    phx-submit="create_todo"
  >
    <%= label(f, :text) %>
    <%= text_input(f, :text) %>
    <%= error_tag(f, :text) %>

    <%= submit("Add todo", phx_disable_with: "Adding...") %>
  </.form>
</div>
Enter fullscreen mode Exit fullscreen mode

phx-target={@myself} indicates that events will be handled by the same component. create_todo event fires when the user submits the form. validate event will fire on input change.

Update lib/todo_live_view_web/live/todos_live.ex with the following changes:

.
.
.

@todos_topic "todos"

def mount(_params, _session, socket) do
  if connected?(socket), do: TodoLiveViewWeb.Endpoint.subscribe(@todos_topic)

  {:ok,
   assign(socket, items: Todos.list_todos())
   |> assign_todo()}
end
.
.
.
def handle_info(%{event: "todos_updated", payload: %{items: items}}, socket) do
  {:noreply, assign(socket, items: items)}
end
Enter fullscreen mode Exit fullscreen mode

We are subscribing to @todos_topic when the liveview is connected. We are handling todos_updated event by updating our items on the socket. That way, our todos list live component will be able to display the correct items on the screen.

Add the following styles to assets/css/app.css.

li {
  list-style: none;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
li button.delete_btn {
  height: unset;
  line-height: unset;
  background-color: #d11a2a;
}
p.todo_item {
  cursor: pointer;
  margin-bottom: 16px;
}
p.todo_item.completed {
  text-decoration: line-through;
}
Enter fullscreen mode Exit fullscreen mode

Add the following test in test/todo_live_view_web/live/todos_live_test.exs.

defmodule TodoLiveViewWeb.TodosLiveTest do
  use TodoLiveViewWeb.ConnCase
  import Phoenix.LiveViewTest

  @create_todo_attrs %{text: "first item"}
  @invalid_todo_attrs %{text: nil}

  test "create todo", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/")

    assert view
           |> form("#todo-form", todo: @invalid_todo_attrs)
           |> render_change() =~ "can&#39;t be blank"

    view
    |> form("#todo-form", todo: @create_todo_attrs)
    |> render_submit()

    assert render(view) =~ "first item"
  end
end
Enter fullscreen mode Exit fullscreen mode

All tests should pass after running mix test.

...........
Finished in 0.5 seconds (0.2s async, 0.3s sync)
11 tests, 0 failures

Randomized with seed 146173
Enter fullscreen mode Exit fullscreen mode

You should be able to add and view the added item as shown in the image below:

App state after enabling todo item creation

Toggle todo item.

Update lib/todo_live_view_web/live/todos_list_component.ex with the following changes:

.
.
.
alias TodoLiveView.Todos

@todos_topic "todos"

def handle_event("toggle_todo", %{"id" => id}, socket) do
  todo = Todos.get_todo!(id)
  Todos.update_todo(todo, %{completed: !todo.completed})

  socket = assign(socket, items: Todos.list_todos())
  TodoLiveViewWeb.Endpoint.broadcast(@todos_topic, "todos_updated", socket.assigns)
  {:noreply, socket}
end

def item_completed?(item) do
  if item.completed == true, do: "completed", else: ""
end
.
.
.
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here. We have a function that determines whether the item is completed and returns the appropriate CSS class. We are updating the todo item and broadcasting the updated list of items.

Update lib/todo_live_view_web/live/todos_list_component.html.heex to the following:

<div>
  <ul>
    <%= for item <- @items do %>
      <li id={"item-#{item.id}"}>
        <%= checkbox(
          :item,
          :completed,
          phx_click: "toggle_todo",
          phx_value_id: item.id,
          phx_target: @myself,
          value: item.completed
        ) %>
        <p
          class={"todo_item #{item_completed?(item)}"}
          phx-click="toggle_todo"
          phx-value-id={item.id}
          phx-target={@myself}
        >
          <%= item.text %>
        </p>
        <button
          class="delete_btn"
        >
          Delete
        </button>
      </li>
    <% end %>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

We are toggling an item with both the checkbox click and a click on the item text. We are passing the id to the event handler with phx-value-id.

Let's add a test. Update test/todo_live_view_web/live/todos_live_test.exs with the following:

.
.
.
alias TodoLiveView.Todos
.
.
.
test "toggle todo item", %{conn: conn} do
  {:ok, todo} = Todos.create_todo(%{"text" => "first item"})
  assert todo.completed == false

  {:ok, view, _html} = live(conn, "/")
  assert view |> element("#item_completed") |> render_click() =~ "completed"

  updated_todo = Todos.get_todo!(todo.id)
  assert updated_todo.completed == true
end
Enter fullscreen mode Exit fullscreen mode

All tests should be passing and the functionality should be working.

............
Finished in 0.5 seconds (0.1s async, 0.3s sync)
12 tests, 0 failures

Randomized with seed 331510
Enter fullscreen mode Exit fullscreen mode

Delete todo item.

Update lib/todo_live_view_web/live/todos_list_component.ex with the following:

.
.
.
def handle_event("delete_todo", %{"id" => id}, socket) do
  todo = Todos.get_todo!(id)
  Todos.delete_todo(todo)

  socket = assign(socket, items: Todos.list_todos())
  TodoLiveViewWeb.Endpoint.broadcast(@todos_topic, "todos_updated", socket.assigns)
  {:noreply, socket}
end
.
.
.
Enter fullscreen mode Exit fullscreen mode

Place handle_event functions next to each other.

Update the Delete button in lib/todo_live_view_web/live/todos_list_component.html.heex to the following:

.
.
.
<button
  class="delete_btn"
  phx-click="delete_todo"
  phx-value-id={item.id}
  phx-target={@myself}
>
  Delete
</button>
Enter fullscreen mode Exit fullscreen mode

Add a test. Update test/todo_live_view_web/live/todos_live_test.exs with the following:

.
.
.
test "delete todo item", %{conn: conn} do
  {:ok, todo} = Todos.create_todo(%{"text" => "first item"})
  assert todo.completed == false

  {:ok, view, _html} = live(conn, "/")
  view |> element("button", "Delete") |> render_click()
  refute has_element?(view, "#item-#{todo.id}")
end
Enter fullscreen mode Exit fullscreen mode

All tests should be passing and the functionality should be working.

.............
Finished in 0.5 seconds (0.1s async, 0.3s sync)
13 tests, 0 failures

Randomized with seed 468301
Enter fullscreen mode Exit fullscreen mode

Add filters.

We are almost done with the tutorial. Update lib/todo_live_view_web/live/todos_live.ex to the following:

.
.
.

def handle_params(params, _url, socket) do
  items = Todos.list_todos()

  case params["filter_by"] do
    "completed" ->
      completed_items = Enum.filter(items, &(&1.completed == true))
      {:noreply, assign(socket, items: completed_items)}

    "active" ->
      active_items = Enum.filter(items, &(&1.completed == false))
      {:noreply, assign(socket, items: active_items)}

    _ ->
      {:noreply, assign(socket, items: items)}
  end
end
Enter fullscreen mode Exit fullscreen mode

This function will handle params from live patch links. More details are to be provided soon. We are filtering and updating the socket with filtered items.

Update lib/todo_live_view_web/live/todos_live.html.heex with the following:

.
.
.
<div>
  Show:
  <span>
    <%= live_patch("All",
      to: Routes.live_path(@socket, TodoLiveViewWeb.TodosLive, %{filter_by: "all"})
    ) %>
  </span>
  <span>
    <%= live_patch("Active",
      to: Routes.live_path(@socket, TodoLiveViewWeb.TodosLive, %{filter_by: "active"})
    ) %>
  </span>
  <span>
    <%= live_patch("Completed",
      to: Routes.live_path(@socket, TodoLiveViewWeb.TodosLive, %{filter_by: "completed"})
    ) %>
  </span>
</div>
Enter fullscreen mode Exit fullscreen mode

A live patch link patches the current live view - the link will change the URL in the browser with the help of JavaScript's push state navigation feature but it won't send a web request to reload the page. When a live patch link is cliked, handle_params/3 function will be invoked for the linked LiveView, followed by the render/1 function.

Add a test. Update test/todo_live_view_web/live/todos_live_test.exs with the following:

.
.
.
test "Filter todo items", %{conn: conn} do
  {:ok, _first_todo} = Todos.create_todo(%{"text" => "first item"})
  {:ok, _second_todo} = Todos.create_todo(%{"text" => "second item"})

  {:ok, view, _html} = live(conn, "/")

  # complete first item
  assert view |> element("p", "first item") |> render_click() =~
           "completed"

  # completed first item should be visible
  {:ok, view, _html} = live(conn, "/?filter_by=completed")
  assert render(view) =~ "first item"
  refute render(view) =~ "second item"

  # active second item should be visible
  {:ok, view, _html} = live(conn, "/?filter_by=active")
  refute render(view) =~ "first item"
  assert render(view) =~ "second item"

  # All items should be visible
  {:ok, view, _html} = live(conn, "/?filter_by=all")
  assert render(view) =~ "first item"
  assert render(view) =~ "second item"
end
Enter fullscreen mode Exit fullscreen mode

All tests should be passing and the functionality should be working as shown in the first gif at the beginning of the tutorial.

..............
Finished in 0.5 seconds (0.2s async, 0.3s sync)
14 tests, 0 failures

Randomized with seed 807925
Enter fullscreen mode Exit fullscreen mode

Congratulations! You have successfully built a functional todo list with Phoenix LiveView. I hope this tutorial has been informative and helpful in improving your coding skills. Don't stop here, though - keep experimenting and learning to enhance your knowledge and expertise.

The complete code is available on my Github account https://github.com/collinewait/todo_live_view.

Top comments (3)

Collapse
 
long-dev profile image
longnight

It can't go down, saying:

error: undefined function checkbox/3 (expected TodoLiveViewWeb.TodosListComponent to define such a function or for it to be imported, but none are available)
lib/todo_live_view_web/live/todos_list_component.html.heex:5: TodoLiveViewWeb.TodosListComponent.render/1

error: undefined variable "f"
lib/todo_live_view_web/live/form_component.html.heex:3: TodoLiveViewWeb.FormComponent.render/1

...

Collapse
 
studentops profile image
Ryuta Sakamoto

I have same

Collapse
 
csarnataro profile image
Christian Sarnataro

@studentops @long-dev
The errors you're experiencing are probably due to the fact that this tutorial has been generated a few months ago, with Phoenix 1.6.14 and Liveview 0.17.5.
But if you launch the generator mix phx.new todo_live_view today (December 2023), the new application will be created with Phoenix 1.7.7 and LiveView 0.19.0.
While the main concepts are still valid, some minor details have changed since then.

For example the error related to the checkbox is caused by the fact that today forms are rendered via livewiew components, e.g.:
<.input type="checkbox" ... instead of <%= checkbox(...

I created a repo with the same application, slightly adapted to use Phoenix 1.7.7 and LiveView 0.19.0.
Check it out here:
github.com/csarnataro/todo_live_view