Skip to content

✅ Beginners tutorial building a Realtime Todo List in Phoenix 1.6.10 + LiveView 0.17.10 ⚡️ Feedback very welcome!

Notifications You must be signed in to change notification settings

dwyl/phoenix-liveview-todo-list-tutorial

Repository files navigation

Phoenix LiveView Todo List Tutorial

GitHub Workflow Status codecov.io Hex pm contributions welcome HitCount

Build your second App using Phoenix LiveView
and understand how to build real-world apps in 20 minutes or less!



Why? 🤷

Phoenix is already an awesome web framework that helps teams build reliable Apps & APIs fast.
LiveView takes the simplicity of building realtime features to the next level of elegance and simplicity.

LiveView lets us create a slick single-page app with a native (no lag or refresh) experience without writing JavaScript.

What? 💭

This tutorial builds a Todo List from scratch using Phoenix LiveView in less than 20 minutes.

Who? 👤

This tutorial is aimed at LiveView beginners who want to understand how everything works using a familiar UI.

If you are completely new to Phoenix and LiveView, we recommend you follow the LiveView Counter Tutorial: https://github.com/dwyl/phoenix-liveview-counter-tutorial

Prerequisites: What you Need Before You Start 📝

This tutorial expects you have a Elixir, Phoenix and Node.js installed. If you don't already have them on your computer, please see: https://github.com/dwyl/learn-elixir#installation and https://hexdocs.pm/phoenix/installation.html#phoenix

How? 💻

💡 You can also try the version deployed to Heroku: https://live-view-todo.herokuapp.com/


Step 0: Run the Finished Todo App on your localhost 🏃‍

Before you attempt to build the todo list app, we suggest that you clone and run the complete app on your localhost.
That way you know it's working without much effort/time expended.

Clone the Repository

On your localhost, run the following command to clone the repo and change into the directory:

git clone https://github.com/dwyl/phoenix-liveview-todo-list-tutorial.git
cd phoenix-liveview-todo-list-tutorial

Download the Dependencies

Install the dependencies by running the command:

mix setup

It will take a few seconds to download the dependencies depending on the speed of your internet connection; be patient. 😉

Run the App

Start the Phoenix server by running the command:

mix phx.server

Now you can visit localhost:4000 in your web browser.

💡 Open a second browser window (e.g. incognito mode), you will see the the counter updating in both places like magic!

You should expect to see:

With the finished version of the App running on your machine and a clear picture of where we are headed, it's time to build it!


Step 1: Create the App 🆕

In your terminal run the following mix command to generate the new Phoenix app:

mix phx.new live_view_todo

This command will setup the dependencies (including the liveView dependencies) and boilerplate for us to get going as fast as possible.

When you see the following prompt in your terminal:

Fetch and install dependencies? [Yn]

Type Y followed by the Enter key. That will download all the necessary dependencies.

Checkpoint 1a: Run the Tests!

In your terminal, go into the newly created app folder using:

cd live_view_todo

And then run the following mix command:

mix test

After the application is compiled you should see:

...

Finished in 0.1 seconds (0.08s async, 0.05s sync)
3 tests, 0 failures

Tests all pass. This is expected with a new app. It's a good way to confirm everything is working.


Checkpoint 1b: Run the New Phoenix App!

Run the server by executing this command:

mix phx.server

Visit localhost:4000 in your web browser.

welcome-to-phoenix-liveview

🏁 Snapshot of code at the end of Step 1: #25ba4e7


2. Create the TodoMVC UI/UX

As we saw in the previous step, our App looks like a fresh Phoenix App. Let's make it look like a todo list.

2.1 Create live folder

By convention Phoenix uses a live folder to manage the LiveView files. Create this folder at lib/live_view_todo_web/live.

Next we can create the PageLive controller module. Create the lib/live_view_todo_web/live/page_live.ex and add the following content:

defmodule LiveViewTodoWeb.PageLive do
  use LiveViewTodoWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end

When using LiveView, the controller is required to implement the mount function, the entry point of the live page.

2.2 Update the Root Layout

Open the lib/live_view_todo_web/templates/layout/root.html.heex file and remove the <header> section such that the contents file is the following:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <%= csrf_meta_tag() %>
    <%= live_title_tag assigns[:page_title] || "LiveViewTodo", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
    <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
  </head>
  <body>
    <%= @inner_content %>
  </body>
</html>

2.3 Create the page_live layout

Create the lib/live_view_todo_web/live/page_live.html.heex layout file and add the following content:

<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <input class="new-todo" placeholder="What needs to be done?" autofocus="" />
  </header>
  <section class="main" style="display: block;">
    <input id="toggle-all" class="toggle-all" type="checkbox" />
    <label for="toggle-all">Mark all as complete</label>
    <ul class="todo-list">
      <li data-id="1590167947253" class="">
        <div class="view">
          <input class="toggle" type="checkbox" />
          <label>Learn how to build a Todo list in Phoenix</label>
          <button class="destroy"></button>
        </div>
      </li>
      <li data-id="1590167956628" class="completed">
        <div class="view">
          <input class="toggle" type="checkbox" />
          <label>Completed item</label>
          <button class="destroy"></button>
        </div>
      </li>
    </ul>
  </section>
  <footer class="footer" style="display: block;">
    <span class="todo-count"><strong>1</strong> item left</span>
    <ul class="filters">
      <li>
        <a href="#/" class="selected">All</a>
      </li>
      <li>
        <a href="#/active">Active</a>
      </li>
      <li>
        <a href="#/completed">Completed</a>
      </li>
    </ul>
    <button class="clear-completed" style="display: block;">
      Clear completed
    </button>
  </footer>
</section>

Note: we borrowed this code from: https://github.com/dwyl/phoenix-todo-list-tutorial#3-create-the-todomvc-uiux our Phoenix (without LiveView) Todo List Tutorial.

2.4 Update Router and controller

in lib/live_view_todo_web/router.ex file change get to live and rename the controller PageController to PageLive

from:

 scope "/", LiveViewTodoWeb do
    pipe_through :browser

    get "/", PageController, :index
  end

to:

 scope "/", LiveViewTodoWeb do
    pipe_through :browser

    live "/", PageLive
  end

If you attempt to run the app now mix phx.server and visit http://localhost:4000
You will see this (without the TodoMVC CSS):

before-adding-css

That's obviously not what we want, so let's get the TodoMVC CSS and save it in our project!

2.5 Save the TodoMVC CSS to /assets/css

Visit https://todomvc.com/examples/vanillajs/node_modules/todomvc-app-css/index.css
and save the file to /assets/css/todomvc-app.css

e.g: /assets/css/todomvc-app.css


2.6 Import the todomvc-app.css in app.scss

Open the assets/css/app.scss file and replace it with the following:

/* This file is for your main application css. */
/* @import "./phoenix.css"; */
@import "./todomvc-app.css";

We also commented out the line @import "./phoenix.css"; because we don't want the Phoenix (Milligram) styles conflicting with the TodoMVC ones.

At the end of this step, if you run your Phoenix App with mix phx.server and visit: http://localhost:4000 you should see the following:

todo-list-ui

Now that we have the layout looking like we want it, we can move onto the fun part of making it work.

2.7 Update the test

Now that we have a functioning LiveView page, let's create the tests under test/live_view_todo_web/live folder. Create the file test/live_view_todo_web/live/page_live_test.exs and add the following:

defmodule LiveViewTodoWeb.PageLiveTest do
 use LiveViewTodoWeb.ConnCase
 import Phoenix.LiveViewTest

 test "disconnected and connected mount", %{conn: conn} do
   {:ok, page_live, disconnected_html} = live(conn, "/")
   assert disconnected_html =~ "Todo"
   assert render(page_live) =~ "What needs to be done"
 end
end

and delete the test/live_view_todo_web/controllers/page_controller_test.exs file.

Now when you re-run the tests:

mix test

You should see:

Compiling 1 file (.ex)
...

Finished in 0.2 seconds
3 tests, 0 failures

Everything passing, lets get back to building!


3. Create the Todo List items Schema

In order to store the todo list items we need a schema. In your terminal run the following generator command:

mix phx.gen.schema Item items text:string person_id:integer status:integer

That will create two new files:

  • lib/live_view_todo/item.ex - the schema
  • priv/repo/migrations/20201227070700_create_items.exs - migration file (creates database table)

Open the migration file to add a default value to status:

add :status, :integer, default: 0  # add default value 0

Reference: https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Schema.html

Execute the migration file by running the following command:

mix ecto.migrate

You will see output similar to the following:

13:44:03.406 [info]  == Migrated 20170606070700 in 0.0s

Now that the schema has been created we can write some code to make the todo list functionality work.

3.1 Add Aliases to item.ex

Before we create any new functions, let's open the lib/live_view_todo/item.ex file and make a couple of changes:

defmodule LiveViewTodo.Item do
  use Ecto.Schema
  import Ecto.Changeset

  schema "items" do
    field :person_id, :integer
    field :status, :integer
    field :text, :string

    timestamps()
  end

  @doc false
  def changeset(item, attrs) do
    item
    |> cast(attrs, [:text, :person_id, :status])
    |> validate_required([:text, :person_id, :status])
  end
end

First add the line alias LiveViewTodo.Repo below the import Ecto.Changeset statement; we need this alias so that we can make database queries.

Next add the line alias __MODULE__ below the alias we just added; this just means "alias the Struct contained in this file so we can reference it". see: https://stackoverflow.com/questions/39854281/access-struct-inside-module/47501059

Then add the default value for status to 0:

field :status, :integer, default: 0

Finally remove the :person_id, :status from the List of fields in validate_required. We don't want person_id to be required for now as we don't yet have authentication setup for the App.

Your file should now look like this:

defmodule LiveViewTodo.Item do
  use Ecto.Schema
  import Ecto.Changeset
  alias LiveViewTodo.Repo
  alias __MODULE__

  schema "items" do
    field :person_id, :integer
    field :status, :integer
    field :text, :string

    timestamps()
  end

  @doc false
  def changeset(item, attrs) do
    item
    |> cast(attrs, [:text, :person_id, :status])
    |> validate_required([:text])
  end
end

With those changes made, we can proceed to creating our functions.

3.2 Create Todo Item CRUD Tests

The phx.gen.schema does not automatically create any "CRUD" functions to Create an item or Read items in/from the database or tests for those functions, so we need to create them ourselves now.

Create a new directory with the path: test/live_view_todo and in that new directory, create a file: test/live_view_todo/item_test.exs

Next open the newly created file test/live_view_todo/item_test.exs and add the following test code to it:

defmodule LiveViewTodo.ItemTest do
  use LiveViewTodo.DataCase
  alias LiveViewTodo.Item

  describe "items" do
    @valid_attrs %{text: "some text", person_id: 1, status: 0}
    @update_attrs %{text: "some updated text", status: 1}
    @invalid_attrs %{text: nil}

    def item_fixture(attrs \\ %{}) do
      {:ok, item} =
        attrs
        |> Enum.into(@valid_attrs)
        |> Item.create_item()

      item
    end

    test "get_item!/1 returns the item with given id" do
      item = item_fixture(@valid_attrs)
      assert Item.get_item!(item.id) == item
    end

    test "create_item/1 with valid data creates a item" do
      assert {:ok, %Item{} = item} = Item.create_item(@valid_attrs)
      assert item.text == "some text"

      inserted_item = List.first(Item.list_items())
      assert inserted_item.text == @valid_attrs.text
    end

    test "create_item/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Item.create_item(@invalid_attrs)
    end

    test "list_items/0 returns a list of todo items stored in the DB" do
      item1 = item_fixture()
      item2 = item_fixture()
      items = Item.list_items()
      assert Enum.member?(items, item1)
      assert Enum.member?(items, item2)
    end

    test "update_item/2 with valid data updates the item" do
      item = item_fixture()
      assert {:ok, %Item{} = item} = Item.update_item(item, @update_attrs)
      assert item.text == "some updated text"
    end
  end
end

Take a moment to understand what is being tested. Once you have written out (or let's face it, copy-pasted) the test code, save the file and run the tests:

mix test test/live_view_todo/item_test.exs

Since the functions don't yet exist, you will see all the test fail:

  1) test items get_item!/1 returns the item with given id (LiveViewTodo.ItemTest)
     test/live_view_todo/item_test.exs:19
     ** (UndefinedFunctionError) function LiveViewTodo.Item.create_item/1 is undefined or private
     code: item = item_fixture(@valid_attrs)
     stacktrace:
       (live_view_todo 0.1.0) LiveViewTodo.Item.create_item(%{person_id: 1, text: "some text"})
       test/live_view_todo/item_test.exs:14: LiveViewTodo.ItemTest.item_fixture/1
       test/live_view_todo/item_test.exs:20: (test)

etc ...

Finished in 0.2 seconds
5 tests, 5 failures

Hopefully these CRUD tests are familiar to you. If they aren't, please read: https://hexdocs.pm/phoenix/testing.html
If you still have any doubts, please ask a specific question.

The focus of this tutorial is LiveView not CRUD testing, the sooner we get to the LievView part the better, this is just the "setup" we need to do for inserting todo item data.

Let's write the functions to make the tests pass!

3.3 Make the CRUD Tests Pass

Open the lib/live_view_todo/item.ex file and add the following lines of code:

@doc """
Creates a item.

## Examples

    iex> create_item(%{text: "Learn LiveView"})
    {:ok, %Item{}}

    iex> create_item(%{text: nil})
    {:error, %Ecto.Changeset{}}

"""
def create_item(attrs \\ %{}) do
  %Item{}
  |> changeset(attrs)
  |> Repo.insert()
end

@doc """
Gets a single item.

Raises `Ecto.NoResultsError` if the Item does not exist.

## Examples

    iex> get_item!(123)
    %Item{}

    iex> get_item!(456)
    ** (Ecto.NoResultsError)

"""
def get_item!(id), do: Repo.get!(Item, id)


@doc """
Returns the list of items.

## Examples

    iex> list_items()
    [%Item{}, ...]

"""
def list_items do
  Repo.all(Item)
end

@doc """
Updates a item.

## Examples

    iex> update_item(item, %{field: new_value})
    {:ok, %Item{}}

    iex> update_item(item, %{field: bad_value})
    {:error, %Ecto.Changeset{}}

"""
def update_item(%Item{} = item, attrs) do
  item
  |> Item.changeset(attrs)
  |> Repo.update()
end

After saving the item.ex file, re-run the tests with:

mix test test/live_view_todo/item_test.exs

You should see them pass:

.....

Finished in 0.2 seconds
5 tests, 0 failures

Randomized with seed 208543

Now that we have our CRUD functions written (and documented+tested), we can move on to the fun part, building the Todo App in LiveView!


4. Handle Todo List Item Creation

The first event we want to handle in our LiveView App is "create"; the act of creating a new Todo List item.

Let's start by adding a test for creating an item. Open the test/live_view_todo_web/live/page_live_test.exs file and add the following test:

test "connect and create a todo item", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/")
  assert render_submit(view, :create, %{"text" => "Learn Elixir"}) =~ "Learn Elixir"
end

Docs for this LiveView testing using render_submit/1: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html#render_submit/1


If you attempt to run this test:

mix test test/live_view_todo_web/live/page_live_test.exs

you will see it fail:

1) test connect and create a todo item (LiveViewTodoWeb.PageLiveTest)
    test/live_view_todo_web/live/page_live_test.exs:12
    ** (EXIT from #PID<0.441.0>) an exception was raised:

        ** (FunctionClauseError) no function clause matching in LiveViewTodoWeb.PageLive.handle_event/3

In order to make the test pass we will need to add two blocks of code.

Open the lib/live_view_todo_web/live/page_live.html.heex file and locate the line in the <header> section:

<input class="new-todo" placeholder="What needs to be done?" autofocus="" />

Replace it with the following:

<form phx-submit="create" id="form">
  <input
    id="new_todo"
    class="new-todo"
    type="text"
    name="text"
    placeholder="What needs to be done?"
    autofocus=""
    required="required"
  />
</form>

The important part is the phx-submit="create" which tells LiveView which event to emit when the form is submitted.

Once you've saved the page_live.html.leex file, open the lib/live_view_todo_web/live/page_live.ex file and under use LiveViewTodoWeb, :live_view add

alias LiveViewTodo.Item

@topic "live"

and the add the following handler code after the mount function:

@impl true
def handle_event("create", %{"text" => text}, socket) do
  Item.create_item(%{text: text})
  socket = assign(socket, items: Item.list_items(), active: %Item{})
  LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
  {:noreply, socket}
end

The @topic "live" is the WebSocket (Phoenix Channel) topic defined as a module attribute (like a Global Constant), which we will use to both subscribe to and broadcast on.

So the following line:

  LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)

Will send the "update" event with the socket.assigns data to all the other clients on listening to the @topic. Now to listen to this message we can define the handle_info callback. Add the following code:

@impl true
def handle_info(%{event: "update", payload: %{items: items}}, socket) do
  {:noreply, assign(socket, items: items)}
end

We are using pattern matching on the first parameter to make sure the handle_info matches the "update" event. We then assign to the socket the new list of items.

With that in place you can now create items in the browser! Run the app: mix phx.sever and you should be able to add items. However they will not appear in the UI. Let's fix that next.


5. Show the Created Todo Items

In order to show the Todo items we are creating, we need to:

  1. Lookup and assign the items in the mount/3 function
  2. Loop through and render the item in the page_live.html.leex template

Let's start by updating the mount/3 function in /lib/live_view_todo_web/live/page_live.ex:

  def mount(_params, _session, socket) do
    # subscribe to the channel
    if connected?(socket), do: LiveViewTodoWeb.Endpoint.subscribe(@topic)
    {:ok, assign(socket, items: Item.list_items())} # add items to assigns
  end

Then in the lib/live_view_todo_web/live/page_live.html.leex file replace the code:

<ul class="todo-list">
  <li data-id="1590167947253" class="">
    <div class="view">
      <input class="toggle" type="checkbox" />
      <label>Learn how to build a Todo list in Phoenix</label>
      <button class="destroy"></button>
    </div>
  </li>
  <li data-id="1590167956628" class="completed">
    <div class="view">
      <input class="toggle" type="checkbox" />
      <label>Completed item</label>
      <button class="destroy"></button>
    </div>
  </li>
</ul>

With the following:

<ul class="todo-list">
    <%= for item <- @items do %>
    <li data-id={item.id} class={completed?(item)}>
      <div class="view">
        <%= if checked?(item) do %>
          <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" checked />
        <% else %>
          <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" />
        <% end %>
        <label><%= item.text %></label>
        <button class="destroy" phx-click="delete" phx-value-id={item.id}></button>
      </div>
    </li>
    <% end %>
</ul>

You will notice that there are two functions completed?/1 and checked?/1 invoked in that block of template code.

We need to define the functions in /lib/live_view_todo_web/live/page_live.ex:

def checked?(item) do
  not is_nil(item.status) and item.status > 0
end

def completed?(item) do
  if not is_nil(item.status) and item.status > 0, do: "completed", else: ""
end

These are convenience functions. We could have embedded this code directly in the template, however we prefer to minimize logic in the templates so that they are easier to read/maintain.

With that template update and helper functions saved, we can now create and see our created Todo item:

todo-items-create


6. Toggle the State of Todo Items

The next piece of functionality we want in a Todo List is the ability to toggle the completion from "todo" to "done".

In our item schema (created in step 3), we defined status as an integer. The default value for item.status when a new item is inserted is 0.


Let's create a (failing) test for toggling items. Open the test/live_view_todo_web/live/page_live_test.exs file and add the following test to it:

test "toggle an item", %{conn: conn} do
  {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"})
  assert item.status == 0

  {:ok, view, _html} = live(conn, "/")
  assert render_click(view, :toggle, %{"id" => item.id, "value" => 1}) =~ "completed"

  updated_item = Item.get_item!(item.id)
  assert updated_item.status == 1
end

Make sure to alias the Item structure in your test file:

defmodule LiveViewTodoWeb.PageLiveTest do
 use LiveViewTodoWeb.ConnCase
 import Phoenix.LiveViewTest
 alias LiveViewTodo.Item # alias Item here

You may have noticed that in the template, we included an <input> with the type="checkbox"

<%= if checked?(item) do %>
  <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" checked />
<% else %>
  <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" />
<% end %>

These lines of code already has everything we need to enable the toggle feature on the front-end, we just need to create a handler in page_live.ex to handle the event.

Open the /lib/live_view_todo_web/live/page_live.ex file and add the following code to it:

@impl true
def handle_event("toggle", data, socket) do
  status = if Map.has_key?(data, "value"), do: 1, else: 0
  item = Item.get_item!(Map.get(data, "id"))
  Item.update_item(item, %{id: item.id, status: status})
  socket = assign(socket, items: Item.list_items(), active: %Item{})
  LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns)
  {:noreply, socket}
end

Note that we are using broadcast/3 instead of broadcast_from/4 to make sure the count of items left is updated for the client itself.

Once you've saved the file, the test will pass.


7. "Delete" a Todo item

Rather than permanently deleting items which destroys history/accountability, we prefer to "soft deletion" which allows people to "undo" the operation.

Open test/live_view_todo/item_test.exs and add the following test to it:

test "delete_item/1 soft-deletes an item" do
  item = item_fixture()
  assert {:ok, %Item{} = deleted_item} = Item.delete_item(item.id)
  assert deleted_item.status == 2
end

If you attempt to run the test, you will see it fail:

1) test items delete_item/1 soft-deltes an item (LiveViewTodo.ItemTest)
    test/live_view_todo/item_test.exs:50
    ** (UndefinedFunctionError) function LiveViewTodo.Item.delete_item/1 is undefined or private
    code: assert {:ok, %Item{} = deleted_item} = Item.delete_item(item.id)
    stacktrace:
      (live_view_todo 0.1.0) LiveViewTodo.Item.delete_item(157)
      test/live_view_todo/item_test.exs:52: (test)

To make the test pass, open your lib/live_view_todo/item.ex file and add the following function definition:

def delete_item(id) do
  get_item!(id)
  |> Item.changeset(%{status: 2})
  |> Repo.update()
end

Having defined the delete/1 function as updating the item.status to 2, we can now create a test for a LiveView handler that invokes this function.

Open the test/live_view_todo_web/live/page_live_test.exs file and add the following test to it:

test "delete an item", %{conn: conn} do
  {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"})
  assert item.status == 0

  {:ok, view, _html} = live(conn, "/")
  assert render_click(view, :delete, %{"id" => item.id})

  updated_item = Item.get_item!(item.id)
  assert updated_item.status == 2
end

To make this test pass, we need to add the following handle_event/3 handler to page_live.ex:

@impl true
def handle_event("delete", data, socket) do
  Item.delete_item(Map.get(data, "id"))
  socket = assign(socket, items: Item.list_items(), active: %Item{})
  LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns)
  {:noreply, socket}
end

This point we've written a bunch of code, let's see it in action in the front-end.

Run the Phoenix Sever: mix phx.server and visit http://localhost:4000 in your web browser. You should see:

liveview-todo-delete


8. Editing Todo item.text

For editing an item we'll continue to use LiveView and:

    1. Display the "edit" form when an item is clicked on
    1. On submit, LiveView will handle the update-item event to update the item

First we want to update the html to display the form when an item is edited:

update lib/live_view_todo_web/live/page_live.html.heex to display the form:

  <ul class="todo-list" id="todo-list-items">
    <%= for item <- @items do %>
      <%= if item.id == @editing do %>
        <form phx-submit="update-item" id="form-update">
          <input
            id="update_todo"
            class="new-todo"
            type="text"
            name="text"
            required="required"
            value={item.text}
          />
          <input type="hidden" name="id" value={item.id}/>
        </form>
      <% else %>
      <li data-id={item.id} class={completed?(item)}>
        <div class="view">
          <%= if checked?(item) do %>
            <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" checked />
          <% else %>
            <input class="toggle" type="checkbox" phx-value-id={item.id} phx-click="toggle" />
          <% end %>
          <label phx-click="edit-item" phx-value-id={item.id}><%= item.text %></label>
          <button class="destroy" phx-click="delete" phx-value-id={item.id}></button>
        </div>
      </li>
      <% end %>
    <% end %>
  </ul>

For each item we check if the item.id matches the @editing value and we display either the form or the label value.

We have added the phx-click="edit-item" event on the label which is used to define the @editing value:

in lib/live_view_todo_web/live/page_live.ex create the logic for edit-item event:

  @impl true
  def handle_event("edit-item", data, socket) do
    {:noreply, assign(socket, editing: String.to_integer(data["id"]))}
  end

We assign the editing value to the socket with the item.id defined by phx-value-id.

Finally we can handle the phx-submit="update-item" event:

  @impl true
  def handle_event("update-item", %{"id" => item_id, "text" => text}, socket) do
    current_item = Item.get_item!(item_id)
    Item.update_item(current_item, %{text: text})
    items = Item.list_items()
    socket = assign(socket, items: items, editing: nil)
    LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
    {:noreply, socket}
  end

We update the item matching the id with the new text value and broadcast the change to the other connected clients.

Let's update the tests to make sure the editing feature is covered:

  test "edit item", %{conn: conn} do
    {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"})

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

    assert render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) =~
             "<form phx-submit=\"update-item\" id=\"form-update\">"
  end

  test "update an item", %{conn: conn} do
    {:ok, item} = Item.create_item(%{"text" => "Learn Elixir"})

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

    assert render_submit(view, "update-item", %{"id" => item.id, "text" => "Learn more Elixir"}) =~
             "Learn more Elixir"

    updated_item = Item.get_item!(item.id)
    assert updated_item.text == "Learn more Elixir"
  end

The first test ensures the form is displayed when the edit-item event is triggered is sent to the LiveView. The second test, make sure the item value is updated when the edit form is submitted.

UI enhancement

We can make the UI a bit better by adding focus to the edited item using Hooks

On the template add a new attribute phx-hook:

<input
  id="update_todo"
  class="new-todo"
  type="text"
  name="text"
  required="required"
  value={item.text}
  phx-hook="FocusInputItem"
/>

Then in app.js add the following:

function focusInput(input) {
  let end = input.value.length;
  input.setSelectionRange(end, end);
  input.focus();
}


let Hooks = {}
Hooks.FocusInputItem = {
  mounted() {
    focusInput(document.getElementById("update_todo"));
  },
  updated() {
    focusInput(document.getElementById("update_todo"));
  }
}

the function focusInput add the focus to the input and place the cursor at the end of the text.

We use this function in the mounted and updated hooks event, mounted for the first time the input is displayed, then updated when the input is dispalayed again for editing other items.

Finally we need to pass our Hooks object to the socket:

let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks })

borrow from: https://github.com/dwyl/phoenix-todo-list-tutorial#8-edit-an-item


9. Footer Navigation

In this section we'll update the footer links "All", "Active" and "Completed" to make sure the LiveView displays only the items with the correct status.

We first need to update the templates lib/live_view_todo_web/live/page_live.html.heex to use the link component. This component allows LiveView to manage the navigation without having to reload the page:

<.link patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "all"})}>All</.link>
<.link patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "active"})}>Active</.link>
<.link patch={Routes.live_path(@socket, LiveViewTodoWeb.PageLive, %{filter_by: "completed"})}>Completed</.link>

The filter_by query parameters can have the "all", "active" or "completed" value.

We then define a new handle_params function in lib/live_view_todo_web/live/page_live.ex:

  @impl true
  def handle_params(params, _url, socket) do
    items = Item.list_items()

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

      "active" ->
        active = Enum.filter(items, &(&1.status == 0))
        {:noreply, assign(socket, items: active, tab: "active")}

      _ ->
        {:noreply, assign(socket, items: items, tab: "all")}
    end
  end

live_patch links will call this function to handle the navigation. The filter_by value is checked and the list of items is filtered. The socket is then updated using assign with the filter list.

Finally we can add a test to make sure only the correct items are displayed. In test/live_view_todo_web/live/page_live_test.exs add:

  test "Filter item", %{conn: conn} do
    {:ok, item1} = Item.create_item(%{"text" => "Learn Elixir"})
    {:ok, _item2} = Item.create_item(%{"text" => "Learn Phoenix"})

    {:ok, view, _html} = live(conn, "/")
    assert render_click(view, :toggle, %{"id" => item1.id, "value" => 1}) =~ "completed"

    # list only completed items
    {:ok, view, _html} = live(conn, "/?filter_by=completed")
    assert render(view) =~ "Learn Elixir"
    refute render(view) =~ "Learn Phoenix"

    # list only active items
    {:ok, view, _html} = live(conn, "/?filter_by=active")
    refute render(view) =~ "Learn Elixir"
    assert render(view) =~ "Learn Phoenix"

    # list all items
    {:ok, view, _html} = live(conn, "/?filter_by=all")
    assert render(view) =~ "Learn Elixir"
    assert render(view) =~ "Learn Phoenix"
  end

Two items are created and one is marked as completed. The view is then rendered multiple times to verify the filter_by param display the correct item

Borrow from: https://github.com/dwyl/phoenix-todo-list-tutorial#9-footer-navigation


10. Clear Completed

To clear completed items the liveview needs to udpate all items with a status defined as 1 to 2.

First we update the "clear completd" button to use the phx-click binding to create a new event, in lib/live_view_todo_web/live/page_live.html.heex update the button to:

<button class="clear-completed" style="display: block;" phx-click="clear-completed">Clear completed</button>

In lib/live_view_todo_web/live/page_live.ex when then define a new handle_event function:

  @impl true
  def handle_event("clear-completed", _data, socket) do
    Item.clear_completed()
    items = Item.list_items()
    {:noreply, assign(socket, items: items)}
  end

In lib/live_view_todo/item.ex we update the list_items function:

  def list_items do
    Item
    |> order_by(desc: :inserted_at)
    |> where([a], is_nil(a.status) or a.status != 2)
    |> Repo.all()
  end

Finally we need to define Item.clear_completed/0 function in lib/live_view_todo/item.ex:

  def clear_completed() do
    completed_items = from(i in Item, where: i.status == 1)
    Repo.update_all(completed_items, set: [status: 2])
  end

We can also add the following test to make sure completed items are removed:

  test "clear completed items", %{conn: conn} do
    {:ok, item1} = Item.create_item(%{"text" => "Learn Elixir"})
    {:ok, _item2} = Item.create_item(%{"text" => "Learn Phoenix"})

    # complete item1
    {:ok, view, _html} = live(conn, "/")
    assert render(view) =~ "Learn Elixir"
    assert render(view) =~ "Learn Phoenix"

    assert render_click(view, :toggle, %{"id" => item1.id, "value" => 1})

    view = render_click(view, "clear-completed", %{})
    assert view =~ "Learn Phoenix"
    refute view =~ "Learn Elixir"
  end

Borrow from: https://github.com/dwyl/phoenix-todo-list-tutorial#10-clear-completed

11. Live Components

LiveView provides the Live Components feature to group UI state and events. In this section we're going to see how to use component for items.

The first step is to create a new file: lib/live_view_todo_web/live/item_component.ex

With the following code:

defmodule LiveViewTodoWeb.ItemComponent do
  use LiveViewTodoWeb, :live_component
  alias LiveViewTodo.Item

  attr(:items, :list, default: [])

  def render(assigns) do
    ~H"""
    <ul class="todo-list" id="todo-list-items">
      <%= for item <- @items do %>
        <%= if item.id == @editing do %>
          <form phx-submit="update-item" id="form-update" phx-target={@myself}>
            <input
              id="update_todo"
              class="new-todo"
              type="text"
              name="text"
              required="required"
              value={item.text}
              phx-hook="FocusInputItem"
            />
            <input type="hidden" name="id" value={item.id} />
          </form>
        <% else %>
          <li data-id={item.id} class={completed?(item)}>
            <div class="view">
              <input
                class="toggle"
                type="checkbox"
                phx-value-id={item.id}
                phx-click="toggle"
                checked={checked?(item)}
                phx-target={@myself}
                id={"item-#{item.id}"}
              />
              <label
                phx-click="edit-item"
                phx-value-id={item.id}
                phx-target={@myself}
                id={"edit-item-#{item.id}"}
              >
                <%= item.text %>
              </label>
              <button
                class="destroy"
                phx-click="delete"
                phx-value-id={item.id}
                phx-target={@myself}
                id={"delete-item-#{item.id}"}
              >
              </button>
            </div>
          </li>
        <% end %>
      <% end %>
    </ul>
    """
  end
end

We have defined the render function which display the list of items. Note that we have also defined the attr function. This tells us that we need to pass the :items attribute when calling our component.

In lib/live_view_todo_web/live/page_live.html.heex we can already call our component:

<section class="main" style="display: block;">
  <input id="toggle-all" class="toggle-all" type="checkbox" />
  <label for="toggle-all">Mark all as complete</label>
  <.live_component
    module={LiveViewTodoWeb.ItemComponent}
    id="cpn"
    items={@items}
    editing={@editing}
  />
</section>

Now that we have moved the ul and li tags to the render function we can directly use <.live_component/>. Make sure to define the module and id. We can also see that we have the items and editing attribute too.

Finally we can move the handle_event linked to the items in live_page.ex to the item_component.ex file:

  def render(assigns) do
  ...
  end

  @impl true
  def handle_event("toggle", data, socket) do
    status = if Map.has_key?(data, "value"), do: 1, else: 0
    item = Item.get_item!(Map.get(data, "id"))

    Item.update_item(item, %{id: item.id, status: status})

    socket = assign(socket, items: Item.list_items(), active: %Item{})
    LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
    {:noreply, socket}
  end

  @impl true
  def handle_event("edit-item", data, socket) do
    {:noreply, assign(socket, editing: String.to_integer(data["id"]))}
  end

  @impl true
  def handle_event("update-item", %{"id" => item_id, "text" => text}, socket) do
    current_item = Item.get_item!(item_id)
    Item.update_item(current_item, %{text: text})
    items = Item.list_items()
    socket = assign(socket, items: items, editing: nil)
    LiveViewTodoWeb.Endpoint.broadcast_from(self(), @topic, "update", socket.assigns)
    {:noreply, socket}
  end

  @impl true
  def handle_event("delete", data, socket) do
    Item.delete_item(Map.get(data, "id"))
    socket = assign(socket, items: Item.list_items(), active: %Item{})
    LiveViewTodoWeb.Endpoint.broadcast(@topic, "update", socket.assigns)
    {:noreply, socket}
  end

  def checked?(item) do
    not is_nil(item.status) and item.status > 0
  end

  def completed?(item) do
    if not is_nil(item.status) and item.status > 0, do: "completed", else: ""
  end

More documentation:

12. Deploy to Heroku

Deployment is beyond the scope of this tutorial. But we created a separate guide for it: elixir-phoenix-app-deployment.md

Once you have deployed you will will be able to view/use your app in any Web/Mobile Browser.

e.g: https://liveview-todo.herokuapp.com

tl;dr

  • Add the build packs

Run the commands:

heroku git:remote -a liveview-todo
heroku run "POOL_SIZE=2 mix ecto.migrate"