How to Build a Phoenix LiveView Todo List App with Testing.
Tags: elixir, phoenixframework, phoenixliveview, todo
Published: 2023-04-07 21:21:23

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4jdkghzydratee3r5thk.gif)


### 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](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html).


![app structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h3ly0dm27al98s6qd43a.jpg)

![File structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/arsy2axp5r9h0cskstmz.png)

### 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
```
_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](localhost:4000).

```
Finished in 0.1 seconds (0.09s async, 0.09s sync)
3 tests, 0 failures
```

![default phoenix web app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/roey7dp1sxn0tsxlelc5.png)

### 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
```
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](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Context.html).

Migrate your database with the following command:

```bash
mix ecto.migrate
```
### 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_

```elixir
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
```

A `LiveView` is a simple module that requires two callbacks: [mount/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3) and [render/1](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c: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](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html#module-managing-state).

_lib/todo_live_view_web/live/todos_live.html.heex_

```html
<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>
```
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_

```elixir
defmodule TodoLiveViewWeb.TodosListComponent do
  use TodoLiveViewWeb, :live_component
end
```
The smallest LiveComponent only needs to define a [render/1](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html#c: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_

```html
<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>
```

_lib/todo_live_view_web/live/form_component.ex_

```elixir
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
```
We will be using a changeset in the form. More details can be found in the [docs](https://hexdocs.pm/phoenix_live_view/form-bindings.html).

_lib/todo_live_view_web/live/form_component.html.heex_

```html
<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>
```
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](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html).

_lib/todo_live_view_web/router.ex_

```elixir
live "/", TodosLive
```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ubll9n62454291ynm4s2.png)
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:

```elixir
.
.
.
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
```
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:

```html
<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>
```
`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:

```elixir
.
.
.

@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
```
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_.

```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;
}
```

Add the following test in _test/todo_live_view_web/live/todos_live_test.exs_.

```elixir
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
```
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
```
You should be able to add and view the added item as shown in the image below:

![App state after enabling todo item creation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vpjbhgihcmpqup4sjj1z.png)

### Toggle todo item.
Update **lib/todo_live_view_web/live/todos_list_component.ex** with the following changes:

```elixir
.
.
.
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
.
.
.
```
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:

```html
<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>
```
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:

```elixir
.
.
.
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
```
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
```

### Delete todo item.
Update _lib/todo_live_view_web/live/todos_list_component.ex_ with the following:

```elixir
.
.
.
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
.
.
.
```
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:

```html
.
.
.
<button
  class="delete_btn"
  phx-click="delete_todo"
  phx-value-id={item.id}
  phx-target={@myself}
>
  Delete
</button>
```

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

```elixir
.
.
.
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
```
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
```

### Add filters.
We are almost done with the tutorial. Update _lib/todo_live_view_web/live/todos_live.ex_ to the following:

```elixir
.
.
.

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
```
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:

```html
.
.
.
<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>
```
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:

```elixir
.
.
.
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
```
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
```

**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](https://github.com/collinewait/todo_live_view).