Popularity
6.0
Stable
Activity
8.1
Declining
54
13
3

Programming language: Elixir

phoenix-todo-list-tutorial alternatives and similar packages

Based on the "Examples and funny stuff" category

Do you think we are missing an alternative of phoenix-todo-list-tutorial or a related project?

Add another 'Examples and funny stuff' Package

README

Phoenix Todo List Tutorial

A complete beginners step-by-step tutorial for building a Todo List in Phoenix. 100% functional. 0% JavaScript. Just HTML, CSS and Elixir. Fast and maintainable.

Build Status codecov.io HitCount contributions welcome

Why? 🤷‍

Todo lists are familiar to most people; we make lists all the time. Building a Todo list from scratch is a great way to learn Elixir/Phoenix because the UI/UX is simple, so we can focus on implementation.

For the team @dwyl this app/tutorial is a showcase of how server side rendering (with client side progressive enhancement) can provide a excellent balance between developer effectiveness (shipping features fast), UX and accessibility. The server rendered pages take less than 5ms to respond so the UX is fast. On Heroku (after the "Free" App wakes up!), round-trip response times are sub 100ms for all interactions, so it feels like a client-side rendered App.

What? 💭

A Todo list tutorial
that shows a complete beginner how to build an app in Elixir/Phoenix from scratch.

Try it on Heroku: phxtodo.herokuapp.com

<!-- wake heroku app before visit. see: https://github.com/dwyl/ping --> wake-sleeping-heroku-app

Try the Heroku version. Add a few items to the list and test the functionality.

todo-app-heroku-version

Even with a full HTTP round-trip for each interaction, the response time is fast. Pay attention to how Chrome|Firefox|Safari waits for the response from the server before re-rendering the page. The old full page refresh of yesteryear is gone. Modern browsers intelligently render just the changes! So the UX approximates "native"! Seriously, try the Heroku app on your Phone and see!

TodoMVC

In this tutorial we are using the TodoMVC CSS to simplify our UI. This has several advantages the biggest being minimising how much CSS we have to write! It also means we have a guide to which features need to be implemented to achieve full functionality.

Note: we love CSS for its incredible power/flexibility, but we know that not everyone like it. see: learn-tachyons#why The last thing we want is to waste tons of time with CSS in a Phoenix tutorial!

Who? 👤

This tutorial is for anyone who is learning to Elixir/Phoenix. No prior experience with Phoenix is assumed/expected. We have included all the steps required to build the app.

If you get stuck on any step, please open an issue on GitHub where we are happy to help you get unstuck! If you feel that any line of code can use a bit more explanation/clarity, please don't hesitate to inform us! We know what it's like to be a beginner, it can be frustrating when something does not make sense! If you're stuck, don't suffer in silence, asking questions on GitHub helps everyone to learn!

How? 👩‍💻

Before You Start! 💡

Before you attempt to build the Todo List, make sure you have everything you need installed on you computer. See: prerequisites

Once you have confirmed that you have Phoenix & PostgreSQL installed, try running the finished App.

0. Run The Finished App on Your localhost 💻

Before you start building your own version of the Todo List App, run the finished version on your localhost to confirm that it works.

Clone the project from GitHub:

git clone git@github.com:dwyl/phoenix-todo-list-tutorial.git && cd phoenix-todo-list-tutorial

Install dependencies and setup the database:

mix setup

Start the Phoenix server:

mix phx.server

Visit localhost:4000 in your web browser.

You should see:

phoenix-todo-list-on-localhost

Now that you have the finished example app running on your localhost, let's build it from scratch and understand all the steps.

If you ran the finished app on your localhost (and you really should!), you will need to change up a directory before starting the tutorial:

cd ..

Now you are ready to build!

1. Create a New Phoenix Project 🆕

In your terminal, create a new Phoenix app using the following mix command:

mix phx.new app

When prompted to install dependencies, type Y followed by Enter.

Change into the newly created app directory (cd app) and ensure you have everything you need:

mix setup

Start the Phoenix server:

mix phx.server

Now you can visit localhost:4000 in your web browser. You should see something similar to:

welcome-to-phoenix

Shut down the Phoenix server ctrl+C.

Run the tests to ensure everything works as expected:

mix test

You should see:

Compiling 16 files (.ex)
Generated app app

17:49:40.111 [info]  Already up
...

Finished in 0.04 seconds
3 tests, 0 failures

Having established that the Phoenix App works as expected, let's move on to creating some files!

2. Create items Schema

In creating a basic Todo List we only need one schema: items. Later we can add separate lists and tags to organise/categorise our items but for now this is all we need.

Run the following generator command to create the items table:

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

Strictly speaking we only need the text and status fields, but since we know we want to associate items with people (later in the tutorial), we are adding the field _now.

You will see the following output:

* creating lib/app_web/controllers/item_controller.ex
* creating lib/app_web/templates/item/edit.html.eex
* creating lib/app_web/templates/item/form.html.eex
* creating lib/app_web/templates/item/index.html.eex
* creating lib/app_web/templates/item/new.html.eex
* creating lib/app_web/templates/item/show.html.eex
* creating lib/app_web/views/item_view.ex
* creating test/app_web/controllers/item_controller_test.exs
* creating lib/app/todo/item.ex
* creating priv/repo/migrations/20200521145424_create_items.exs
* creating lib/app/todo.ex
* injecting lib/app/todo.ex
* creating test/app/todo_test.exs
* injecting test/app/todo_test.exs

Add the resource to your browser scope in lib/app_web/router.ex:

    resources "/items", ItemController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

That created a bunch of files! Some of which we don't strictly need. We could manually create only the files we need, but this is the "official" way of creating a CRUD App in Phoenix, so we are using it for speed.

Note: Phoenix Contexts denoted in this example as Todo, are "dedicated modules that expose and group related functionality." We feel they unnecessarily complicate basic Phoenix Apps with layers of "interface" and we really wish we could avoid them. But given that they are baked into the generators, and the creator of the framework likes them, we have a choice: either get on board with Contexts or manually create all the files in our Phoenix projects. Generators are a much faster way to build! Embrace them, even if you end up having to delete a few unused files along the way!

We are not going to explain each of these files at this stage in the tutorial because it's easier to understand the files as you are building the App! The purpose of each file will become clear as you progress through editing them.

2.1 Add the /items Resources to router.ex

Follow the instructions noted by the generator to add the resources "/items", ItemController to the router.ex.

Open the lib/app_web/router.ex file and locate the line: scope "/", AppWeb do Add the line to the end of the block. e.g:

scope "/", AppWeb do
  pipe_through :browser

  get "/", PageController, :index
  resources "/items", ItemController # this is the new line
end

Your router.ex file should look like this: router.ex#L20

2.2 Run The App!

At this point we already have a functional Todo List (if we were willing to use the default Phoenix UI). Try running the app on your localhost: Run the generated migrations with mix ecto.migrate then the server with:

mix phx.server

Visit: http://localhost:4000/items/new and input some data.

todo-list-phoenix-default-ui

Click the "Save" button and you will be redirected to the "show" page: /items/1

todo-list-phoenix-default-ui-show-item

This is not an attractive User Experience (UX), but it works! Here is a list of items; a "Todo List":

todo-list-phoenix-default-ui-show-items-list

Let's improve the UX by using the TodoMVC HTML and CSS!

3. Create the TodoMVC UI/UX

To recreate the TodoMVC UI/UX, let's borrow the HTML code directly from the example.

Visit: http://todomvc.com/examples/vanillajs add a couple of items to the list, then inspect the source using your browser's Dev Tools. e.g:

todomvc-view-source

Right-click on the source you want (e.g: <section class="todoapp">) and select "Edit as HTML":

edit-as-html

Once the HTML for the <section> is editable, select it and copy it.

todomvc-html-editable-copy

The HTML code is:

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

Let's convert this HTML to an Embedded Elixir (EEx) template.

Note: the reason that we are copying this HTML from the browser's Elements inspector instead of directly from the source on GitHub: examples/vanillajs/index.html is that this is a "single page app", so the <ul class="todo-list"></ul> only gets populated in the browser. Copying it from the browser Dev Tools is the easiest way to get the complete HTML.

3.1 Paste the HTML into index.html.eex

Open the lib/app_web/templates/item/index.html.eex file and scroll to the bottom.

Then (without removing the code that is already there) paste the HTML code we sourced from TodoMVC.

e.g: /lib/app_web/templates/item/index.html.eex#L33-L73

If you attempt to run the app now and visit http://localhost:4000/items/ 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!

3.2 Save the TodoMVC CSS to /assets/css

Visit http://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

3.3 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";

e.g: /assets/css/app.scss#L3

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

3.4 Simplify The Layout Template

Open your lib/app_web/templates/layout/app.html.eex file and replace the contents with the following code:

<!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"/>
    <title>Phoenix Todo List</title>
    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </head>
  <body>
    <main role="main" class="container">
      <%= @inner_content %>
    </main>
  </body>
</html>

Before: /lib/app_web/templates/layout/app.html.eex After: /lib/app_web/templates/layout/app.html.eex#L12

<%= @inner_content %> is where the Todo App will be rendered.

Note: the <script> tag is included out of convention. However, we won't be writing any JavaScript in this tutorial. We will achieve 100% feature parity with TodoMVC, without writing a line of JS. We don't "hate" JS, in fact we have a "sister" tutorial that builds the same App in JS: dwyl/javascript-todo-list-tutorial We just want to remind you that you don't need any JS to build a fully functional web application with great UX!

With the layout template saved, the TodoMVC CSS file saved to /asssets/css/todomvc-app.css and the todomvc-app.css imported in app.scss, your /items page should now look like this:

items-with-todomvc-css

So our Todo List is starting to look like TodoMVC, but it's still just a dummy list.

4. Render Real Data in the TodoMVC Layout

We are going to need a handful of View functions in order to render our item data in the TodoMVC template. Let's create the first two which are fairly basic.

This is our first chance to do a bit of Test Driven Development (TDD). Create a new file with the path test/app_web/views/item_view_test.exs.

Type (or paste) the following code into the file:

defmodule AppWeb.ItemViewTest do
  use AppWeb.ConnCase, async: true
  alias AppWeb.ItemView

  test "complete/1 returns completed if item.status == 1" do
    assert ItemView.complete(%{status: 1}) == "completed"
  end

  test "complete/1 returns empty string if item.status == 0" do
    assert ItemView.complete(%{status: 0}) == ""
  end

  test "checked/1 returns checked if item.status == 1" do
    assert ItemView.checked(%{status: 1}) == "checked"
  end

  test "checked/1 returns empty string if item.status == 0" do
    assert ItemView.checked(%{status: 0}) == ""
  end
end

e.g: /test/app_web/views/item_view_test.exs

If you attempt to run this test file:

mix test test/app_web/views/item_view_test.exs

You will see the following error (because the function does not yet exist!):

== Compilation error in file lib/app_web/views/item_view.ex ==
** (CompileError) lib/app_web/templates/item/index.html.eex:44: undefined function complete/1

Open the lib/app_web/views/item_view.ex file and write the functions to make the tests pass.

This is how we implemented the functions.

# add class "completed" to a list item if item.status=1
def complete(item) do
  case item.status do
    1 -> "completed"
    _ -> "" # empty string means empty class so no style applied
  end
end

# add "checked" to input if item.status=1
def checked(item) do
  case item.status do
    1 -> "checked"
    _ -> "" # empty string means empty class so no style applied
  end
end

e.g: /lib/app_web/views/item_view.ex#L4-L18

Re-run the tests and they should now pass:

mix test test/app_web/views/item_view_test.exs

You should see:

....

Finished in 0.1 seconds
4 tests, 0 failures

Now that we have created these two view functions, and our tests are passing, let's use them in our template!

Open the lib/app_web/templates/item/index.html.eex file and locate the line:

<ul class="todo-list">

Replace the contents of the <ul> with the following:

<%= for item <- @items do %>
  <li data-id="<%= item.id %>" class="<%= complete(item) %>">
    <div class="view">
      <input <%= checked(item) %> class="toggle" type="checkbox">
      <label><%= item.text %></label>
      <%= link "", class: "destroy",
        to: Routes.item_path(@conn, :delete, item), method: :delete,
        data: [confirm: "Are you sure?"] %>
    </div>
  </li>
<% end %>

e.g: /lib/app_web/templates/item/index.html.eex#L43-L53

With those two files saved, if you run the app now: mix phx.server and visit /items You will see the real items you created in step 2.2 (above):

todo-list-real-items

Now that we have our items rendering in the TodoMVC layout, let's work on creating new items in the "single page app" style.

5. In-line the New Item Creation Form

At present our "New Item" form is available at: http://localhost:4000/items/new (as noted in step 2 above)

We want the person to be able to create a new item without having to navigate to a different page. In order to achieve that goal, we will include the form.html template (partial) inside the index.html template. e.g:

<%= render "form.html", Map.put(assigns, :action, Routes.item_path(@conn, :create)) %>

Before we can do that, we need to tidy up the form.html template to remove the fields we don't need.

Let's open lib/app_web/templates/item/form.html.eex and simplify it to just the essential field :text:

<%= form_for @changeset, @action, fn f -> %>
  <%= text_input f, :text, placeholder: "what needs to be done?",
    class: "new-todo", autofocus: "" %>
  <div style="display: none;"> <%= submit "Save" %> </div>
<% end %>

Before: /lib/app_web/templates/item/form.html.eex After: /lib/app_web/templates/item/form.html.eex#L2-L3

If you run the Phoenix App now and visit http://localhost:4000/items/new you will see the single :text input field and no "Save" button:

new-item-single-text-field-no-save-button

Don't worry, you can still submit the form with Enter (Return) key. However if you attempt to submit the form now, it won't work because we removed two of the fields required by the changeset! Let's fix that.

5.1 Update the items Schema to Set default Values

Given that we have removed two of the fields (:person_id and :status) from the form.html.eex, we need to ensure there are default values for these in the schema. Open the lib/app/todo/item.ex file and replace the contents with the following:

defmodule App.Todo.Item do
  use Ecto.Schema
  import Ecto.Changeset

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

    timestamps()
  end

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

Here we are updating the "items" schema to set a default value of 0 for both person_id and status. And in the changeset/2 we are removing the requirement for person_id and status. That way our new item form can be submitted with just the text field.

e.g: /lib/app/todo/item.ex#L6-L7

Now that we have default values for person_id and status if you submit the /items/new form it will succeed.

5.2 Update index/2 in ItemController

In order to in-line the new item form (form.html.eex) in the index.html.eex template, we need to update the AppWeb.ItemController.index/2 to include a Changeset.

Open the lib/app_web/controllers/item_controller.ex file and update the index/2 function to the following:

def index(conn, _params) do
  items = Todo.list_items()
  changeset = Todo.change_item(%Item{})
  render(conn, "index.html", items: items, changeset: changeset)
end

Before: /lib/app_web/controllers/item_controller.ex After: /lib/app_web/controllers/item_controller.ex#L9-L10

You will not see any change in the UI or tests after this step. Just move on to 5.3 where the "aha" moment happens.

5.3 Render The form.html.eex inside index.html.eex

Now that we have done all the preparation work, the next step is to render the form.html.eex (partial) inside index.html.eex template.

Open the lib/app_web/templates/item/index.html.eex file and locate the line:

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

Replace it with this:

<%= render "form.html", Map.put(assigns, :action, Routes.item_path(@conn, :create)) %>

Before: /lib/app_web/templates/item/index.html.eex#L36 After: /lib/app_web/templates/item/index.html.eex#L36

If you run the app now and visit: http://localhost:4000/items You can create an item by typing your text and submit it with the Enter (Return) key.

todo-list-tutorial-step-5

Redirecting to the "show" template is "OK", but we can do better UX by redirecting to back to the index.html template. Thankfully this is as easy as updating a single line in the code.

5.4 Update the redirect in create/2

Open the lib/app_web/controllers/item_controller.ex file and locate the create function. Specifically the line:

|> redirect(to: Routes.item_path(conn, :show, item))

Update the line to:

|> redirect(to: Routes.item_path(conn, :index))

Before: /lib/app_web/controllers/item_controller.ex#L22 After: /lib/app_web/controllers/item_controller.ex#L23

Now when we create a new item we are redirected to the index.html template:

todo-list-tutorial-redirect-to-index

5.5 Update item_controller_test.exs to Redirect to index

The change we just made in Step 5.4 (above) works in the UI, but it breaks one of our automated tests.

Run the tests:

mix test

You will see the following output:

13:46:49.861 [info]  Already up
.........

  1) test create item redirects to show when data is valid (AppWeb.ItemControllerTest)
     test/app_web/controllers/item_controller_test.exs:30
     match (=) failed
     code:  assert %{id: id} = redirected_params(conn)
     left:  %{id: id}
     right: %{}
     stacktrace:
       test/app_web/controllers/item_controller_test.exs:33: (test)

.............

Finished in 0.4 seconds
23 tests, 1 failure

Open the test/app_web/controllers/item_controller_test.exs file and scroll to the line of the failing test.

Replace the test:

test "redirects to show when data is valid", %{conn: conn} do
  conn = post(conn, Routes.item_path(conn, :create), item: @create_attrs)

  assert %{id: id} = redirected_params(conn)
  assert redirected_to(conn) == Routes.item_path(conn, :show, id)

  conn = get(conn, Routes.item_path(conn, :show, id))
  assert html_response(conn, 200) =~ "Show Item"
end

With this updated test:

test "redirects to :index page when item data is valid", %{conn: conn} do
  conn = post(conn, Routes.item_path(conn, :create), item: @create_attrs)

  assert redirected_to(conn) == Routes.item_path(conn, :index)
  assert html_response(conn, 302) =~ "redirected"

  conn = get(conn, Routes.item_path(conn, :index))
  assert html_response(conn, 200) =~ @create_attrs.text
end

Updated code: /test/app_web/controllers/item_controller_test.exs#L30-L38

If you re-run the tests mix test the will now all pass again.

13:53:59.714 [info]  Already up
.......................

Finished in 0.5 seconds
23 tests, 0 failures

6. Display Count of Items in UI

So far the main functionality of the TodoMVC UI is working, we can create new items and they appear in our list. In this step we are going to enhance the UI to include the count of remaining items in the bottom left corner.

Open the test/app_web/views/item_view_test.exs file and create the following two tests:

test "remaining_items/1 returns count of items where item.status==0" do
  items = [
    %{text: "one", status: 0},
    %{text: "two", status: 0},
    %{text: "done", status: 1}
  ]
  assert ItemView.remaining_items(items) == 2
end

test "remaining_items/1 returns 0 (zero) when no items are status==0" do
  items = []
  assert ItemView.remaining_items(items) == 0
end

e.g: /test/app_web/views/item_view_test.exs#L21-L34

These tests will fail because the ItemView.remaining_items/1 function does not exist.

Make the tests pass by adding the following code to the lib/app_web/views/item_view.ex file:

# returns integer value of items where item.status == 0 (not "done")
def remaining_items(items) do
  Enum.filter(items, fn i -> i.status == 0 end) |> Enum.count
end

e.g: /lib/app_web/views/item_view.ex#L20-L23

Now that the tests are passing, use the remaining_items/1 in the index.html template. Open the lib/app_web/templates/item/index.html.eex file and locate the line of code:

<span class="todo-count"><strong>1</strong> item left</span>

Replace it with this line:

<span class="todo-count"><%= remaining_items(@items) %> items left</span>

This just invokes the ItemView.remaining_items/1 function with the List of @items which will return the integer count of remaining items that have not yet been "done".

E.g: /lib/app_web/templates/item/index.html.eex#L58

At this point the (remaining) items counter in the bottom left of the TodoMVC UI is working! Add a new item to your list and watch the count increase:

item-count-increases-to-2

That was easy enough let's try something a bit more advanced! Take a break and grab yourself a fresh glass of water, the next section is going be intense!

7. Toggle a Todo Item's status to 1

One of the core functions of a Todo List is toggling the status of an item from 0 to 1 ("complete"). In our schema a completed item has the status of 1.

7.1 Create the Controller Tests

We are going to need two functions in our controller:

  1. toggle_status/1 toggles the status of an item e.g: 0 to 1 and 1 to 0.
  2. toggle/2 the handler function for HTTP requests to toggle the status of an item.

Open the test/app_web/controllers/item_controller_test.exs file and append the following code to the end:

describe "toggle updates the status of an item 0 > 1 | 1 > 0" do
  setup [:create_item]

  test "toggle_status/1 item.status 1 > 0", %{item: item} do
    assert item.status == 0
    # first toggle
    toggled_item = %{item | status: AppWeb.ItemController.toggle_status(item)}
    assert toggled_item.status == 1
    # second toggle sets status back to 0
    assert AppWeb.ItemController.toggle_status(toggled_item) == 0
  end

  test "toggle/2 updates an item.status 0 > 1", %{conn: conn, item: item} do
    assert item.status == 0
    get(conn, Routes.item_path(conn, :toggle, item.id))
    toggled_item = Todo.get_item!(item.id)
    assert toggled_item.status == 1
  end
end

e.g: /test/app_web/controllers/item_controller_test.exs#L84-L102

7.2 Create the Functions to Make Tests Pass

Open the lib/app_web/controllers/item_controller.ex file and add the following functions to it:

def toggle_status(item) do
  case item.status do
    1 -> 0
    0 -> 1
  end
end

def toggle(conn, %{"id" => id}) do
  item = Todo.get_item!(id)
  Todo.update_item(item, %{status: toggle_status(item)})
  redirect(conn, to: Routes.item_path(conn, :index))
end

e.g: /lib/app_web/controllers/item_controller.ex#L64-L75

The tests will still fail at this point because the route we are invoking in our test does not yet exist. Let's fix that!

7.3 Create get /items/toggle/:id Route that Invokes toggle/2

Open the lib/app_web/router.ex and locate the line resources "/items", ItemController. Add a new line:

get "/items/toggle/:id", ItemController, :toggle

e.g: /lib/app_web/router.ex#L21

Now our tests will finally pass:

mix test

You should see:

22:39:42.231 [info]  Already up
...........................

Finished in 0.5 seconds
27 tests, 0 failures

7.4 Invoke the toggle/2 When a Checkbox is clicked in index.html

Now that our tests are passing, it's time actually use all this functionality we have been building in the UI. Open the /lib/app_web/templates/item/index.html.eex file and locate the line:

<input <%= checked(item) %> class="toggle" type="checkbox">

Replace it with the following:

<a href="<%= Routes.item_path(@conn, :toggle, item.id) %>"
  class="toggle <%= checked(item) %>"></a>

When this link is clicked the get /items/toggle/:id endpoint is invoked, that in turn triggers the toggle/2 handler we defined above.

Before: /lib/app_web/templates/item/index.html.eex#L46 After: /lib/app_web/templates/item/index.html.eex#L46-L48

7.5 Add a .checked CSS to app.scss

Unfortunately, <a> tags cannot have a :checked pseudo selector, so the default TodoMVC styles that worked on the <input> tag will not work for the link. So we need to add a couple of lines of CSS to our app.scss.

Open the assets/css/app.scss file and add the following lines to it:

.todo-list li .checked + label {
    background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
    background-repeat: no-repeat;
}

After saving the file you should have: /assets/css/app.scss#L5-L8

And when you view the app, the Toggle functionality is working as expected:

todo-app-toggle

Implementation Note: we are very deliberately not using an JavaScript in this tutorial because we are demonstrating how to do a 100% server-side rendered App. This always works even when JS is disabled in the browser or the device is super old and does not have a modern web browser. We could easily have added an onclick attribute to the <input> tag, e.g:

<input <%= checked(item) %> type="checkbox" class="toggle"
onclick="location.href='
  <%= Routes.item_path(@conn, :toggle, item.id) %>';">

But onclick is JavaScript and we don't need to resort to JS. The <a> (link) is a perfectly semantic non-js approach to toggling item.status.

8. Edit an Item!

The final piece of functionality we need to add to our UI is the ability to edit an item's text.

At the end of this step you will have in-line editing working:

phoenix-todo-item-inline-editing

8.1 Double-Click Item Text to Edit

The reason for requiring two clicks to edit an item, is so that people don't accidentally edit an item while scrolling. So they have to deliberately click/tap twice in order to edit.

In the TodoMVC spec this is achieved by creating an event listener for the double-click event and replacing the <label> element with an <input>. We are trying to avoid using JavaScript in our server-side rendered Phoenix App (for now), so we want to use an alternative approach. Thankfully we can simulate the double-click event using just HTML and CSS. see: https://css-tricks.com/double-click-in-css (we recommend reading that post and the Demo to fully understand how this CSS works!)

Note: the CSS implementation is not a true double-click, a more accurate description would be "two click" because the two clicks can occur with an arbitrary delay. i.e. first click followed by 10sec wait and second click will have the same effect as two clicks in quick succession. If you want to implement true double-click, see: github.com/dwyl/javascript-todo-list-tutorial#52-double-click

Let's get on with it! Open the lib/app_web/templates/item/index.html.eex file and locate the line:

<%= render "form.html", Map.put(assigns, :action, Routes.item_path(@conn, :create)) %>

Replace it with:

<%= if @editing.id do %>
  <a href="<%= Routes.item_path(@conn, :index) %>" class="new-todo">
    Click here to create a new item!
  </a>
<% else %>
  <%= render "form.html", Map.put(assigns, :action, Routes.item_path(@conn, :create)) %>
<% end %>

This code is the first if/else block in our project. It checks if we are editing an item, and renders a link instead of the form, we do this to avoid having multiple forms on the page. If we are not editing an item, render the form.html as before.

e.g: lib/app_web/templates/item/index.html.eex#L35-L41

Next, still in the index.html.eex file, locate the line:

<div class="view">

Replace the <a> tag with the following code:

<%= if item.id == @editing.id do %>
  <%= render "form.html", Map.put(assigns, :action,
    Routes.item_path(@conn, :update, item)) %>
<% else %>
  <a href="<%= Routes.item_path(@conn, :edit, item) %>" class="dblclick">
    <label><%= item.text %></label>
  </a>
  <span></span> <!-- used for CSS Double Click -->
<% end %>

e.g: lib/app_web/templates/item/index.html.eex#L56-L64

The else block renders a link (<a>), which when clicked will render the App in "edit" mode. We will make the adjustments to the controller to enable editing in the index.html template shortly. The <span></span> as the comment suggests, is only there for the CSS double-click effect.

8.2 Update CSS For Editing

To enable the CSS double-click effect, we need to add the following CSS to our assets/css/app.scss file:

.dblclick {
  position: relative; /* So z-index works later, but no surprises now */
}

.dblclick + span {
  position: absolute;
  top: -1px; /* these negative numbers are to ensure */
  left: -1px; /* that the <span> covers the <a> */
  width: 103%; /* Gotta do this instead of right: 0; */
  bottom: -1px;
  z-index: 1;
}

.dblclick + span:active {
  left: -9999px;
}

.dblclick:hover {
  z-index: 2;
}

e.g: assets/css/app.scss#L48-L67

Additionally, since our markup is slightly different to the TodoMVC markup, we need to add a bit more CSS to keep the UI consistent:

.todo-list li .toggle + div > a > label {
  background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
  background-repeat: no-repeat;
  background-position: center left;
}

.todo-list li .checked + div > a > label
{
  background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
  background-repeat: no-repeat;
  background-position: center left;
}

.toggle {
  width: 10%;
  z-index: 3; /* keep the toggle checkmark above the rest */
}

a.new-todo {
  display: block;
  text-decoration: none;
}

.todo-list .new-todo {
  border: 1px #1abc9c solid;
}

.view a, .view a:visited {
  display: block;
  text-decoration: none;
  color: #2b2d2f;
}

.todo-list li .destroy {
  text-decoration: none;
  text-align: center;
  z-index: 3; /* keep the delete link above the text */
}

This is what your app.scss file should look like at the end of this step: assets/css/app.scss#L7-L44

8.3 Update the ItemController.edit/2 Function

In order to enable in-line editing, we need to modify the edit/2 function. Open the lib/app_web/controllers/item_controller.ex file and replace the edit/2 function with the following:

def edit(conn, params) do
  index(conn, params)
end

Additionally, given that we are asking our index/2 function to handle editing, we need to update index/2:

def index(conn, params) do
  item = if not is_nil(params) and Map.has_key?(params, "id") do
    Todo.get_item!(params["id"])
  else
    %Item{}
  end
  items = Todo.list_items()
  changeset = Todo.change_item(item)
  render(conn, "index.html", items: items, changeset: changeset, editing: item)
end

Your item_controller.ex file should now look like this: lib/app_web/controllers/item_controller.ex#L7-L16

8.4 Update the Tests in ItemControllerTest

In our quest to build a Single Page App, we broke another test! That's OK. It's easy to fix.

Open the test/app_web/controllers/item_controller_test.exs file and locate the test that has the following description:

test "renders form for editing chosen item"

Update the assertion from:

assert html_response(conn, 200) =~ "Edit Item"

To:

assert html_response(conn, 200) =~ item.text

e.g: test/app_web/controllers/item_controller_test.exs#L51

Next, locate the test with the following description:

test "redirects when data is valid"

Update the assertion from:

assert redirected_to(conn) == Routes.item_path(conn, :show, item)

To:

assert redirected_to(conn) == Routes.item_path(conn, :index)

e.g: test/app_web/controllers/item_controller_test.exs#L60

If you run the tests now, they should pass again:

mix test

23:08:01.785 [info]  Already up
...........................

Finished in 0.5 seconds
27 tests, 0 failures

Randomized with seed 956565

8.5 Remove Old Template from index.html

Now that we have the toggle and edit features working, we can finally remove the default Phoenix (table) layout from the index.html.eex template.

Open the lib/app_web/templates/item/index.html.eex file and remove all code before the line:

<section class="todoapp">

e.g: commits/3f5e3e

Your app should now look like this: phoenix-todo-app-without-default-table-layout

Unfortunately, by removing the default layout, we have "broken" the tests.

Open the test/app_web/controllers/item_controller_test.exs file and locate the test that has the following description:

test "lists all items"

Update the assertion from:

assert html_response(conn, 200) =~ "Listing Items"

To:

assert html_response(conn, 200) =~ "todos"

e.g: test/app_web/controllers/item_controller_test.exs#L18

Now that the core (create, edit/update, delete) functionality is working, we can add the final UI enhancements. In this step we are going to add the footer navigation/filtering.

The "All" view is the default. The "Active" is all the items with status==0. "Completed" is all items with status==1.

9.1 Create /:filter Route

Open the lib/app_web/router.ex and add the following route:

get "/:filter", ItemController, :index

e.g: /lib/app_web/router.ex#L22

9.2 Update the Controller index/2 to send filter to View/Template

Open the lib/app_web/controllers/item_controller.ex file and locate the index/2 function. Replace the invocation of render/3 at the end of index/2 with the following:

render(conn, "index.html",
  items: items,
  changeset: changeset,
  editing: item,
  filter: Map.get(params, "filter", "all")
)

e.g: lib/app_web/controllers/item_controller.ex#L22

Map.get(params, "filter", "all") sets the default value of our filter to "all" so when index.html is rendered, show "all" items.

9.3 Create filter/2 and selected/2 View Functions

In order to filter the items by their status, we need to create a new function. Open the lib/app_web/views/item_view.ex file and create the filter/2 function as follows:

def filter(items, str) do
  case str do
    "all" -> items
    "active" -> Enum.filter(items, fn i -> i.status == 0 end)
    "completed" -> Enum.filter(items, fn i -> i.status == 1 end)
  end
end

e.g: lib/app_web/views/item_view.ex#L28-L34

This will allow us to filter the items in the next step.

The other view function we need, will help our view know which filter is selected so that the UI can reflect it correctly. Add the following definition for selected/2:

def selected(filter, str) do
  case filter == str do
    true -> "selected"
    false -> ""
  end
end

e.g: lib/app_web/views/item_view.ex#L36-L41

This will set the "selected" class which will select the appropriate tab in the footer navigation.

Use the filter/2 function to filter the items that are displayed. Open the lib/app_web/templates/item/index.html.eex file and locate the for loop line:

<%= for item <- @items do %>

Replace it with:

<%= for item <- filter(@items, @filter) do %>

e.g: lib/app_web/templates/item/index.html.eex#L17

This invokes the filter/2 function we defined in the previous step passing in the list of @items and the selected @filter.

Next, locate the the <footer> and replace the contents of the <ul class="filters"> with the following code:

<li>
  <a href="/items" class='<%= selected(@filter, "all") %>'>
    All
  </a>
</li>
<li>
  <a href="/active" class='<%= selected(@filter, "active") %>'>
    Active
    [<%= Enum.count(filter(@items, "active")) %>]
  </a>
</li>
<li>
  <a href="/completed" class='<%= selected(@filter, "completed") %>'>
    Completed
    [<%= Enum.count(filter(@items, "completed")) %>]
  </a>
</li>

e.g: /lib/app_web/templates/item/index.html.eex#L46-L64

At the end of this step you will have a fully functioning footer filter:

phoenix-todo-footer-nav

10. Clear Completed

We are almost done with our Phoenix implementation of TodoMVC. The last thing to implement is "clear completed".

Open your lib/app_web/router.ex file and add the following route:

get "/clear", ItemController, :clear_completed

e.g: lib/app_web/router.ex#L22

In the lib/app_web/controllers/item_controller.ex file add the following code:

import Ecto.Query
alias App.Repo

def clear_completed(conn, _param) do
  person_id = 0
  query = from(i in Item, where: i.person_id == ^person_id, where: i.status == 1)
  Repo.update_all(query, set: [status: 2])
  # render the main template:
  index(conn, %{filter: "all"})
end

e.g: lib/app_web/controllers/item_controller.ex#L88-L97

This uses the handy update_all/3 function to update all items that match the query. In our case we searching for all items that belong to person_id==0 and have status==1.

We are not deleting the items, rather we are updating their status to 2 which for the purposes of our example means they are "archived".

Note: This is a useful guide to update_all: https://adamdelong.com/bulk-update-ecto

Finally, in the lib/app_web/templates/item/index.html.eex scroll to the bottom of the file and replace the line:

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

With:

<a class="clear-completed" href="/clear">
  Clear completed
  [<%= Enum.count(filter(@items, "completed")) %>]
</a>

e.g: lib/app_web/templates/item/index.html.eex#L65-L68

At the end of this section your Todo List should have the "Clear completed" function working:

phoenix-todo-clear-completed

11. Tidy Up! (Optional?)

At this point we already have a fully functioning Phoenix Todo List. There are a few things we can tidy up to make the App even better!

11.1 Pluralise Items Left

If you are the type of person to notice the tiny details, you would have been itching each time you saw the "1 items left" in the bottom left corner:

phoenix-todo-pluralisation-BEFORE

Open your test/app_web/views/item_view_test.exs file and add the following test:

test "pluralise/1 returns item for 1 item and items for < 1 <" do
  assert ItemView.pluralise([%{text: "one", status: 0}]) == "item"
  assert ItemView.pluralise([
    %{text: "one", status: 0},
    %{text: "two", status: 0}
  ]) == "items"
  assert ItemView.pluralise([%{text: "one", status: 1}]) == "items"
end

e.g: test/app_web/views/item_view_test.exs#L41-L47

This test will obviously fail because the AppWeb.ItemView.pluralise/1 is undefined. Let's make it pass!

Open your lib/app_web/views/item_view.ex file and add the following function definition for pluralise/1:

# pluralise the word item when the number of items is greather/less than 1
def pluralise(items) do
  # items where status < 1 is equal to Zero or Greater than One:
  case remaining_items(items) == 0 || remaining_items(items) > 1 do
    true -> "items"
    false -> "item"
  end
end

e.g: lib/app_web/views/item_view.ex#L43-L50

Note: we are only pluralising one word in our basic Todo App so we are only handling this one case in our pluralise/1 function. In a more advanced app we would use a translation tool to do this kind of pluralising. See: https://hexdocs.pm/gettext/Gettext.Plural.html

Finally, use the pluralise/1 in our template. Open lib/app_web/templates/item/index.html.eex

Locate the line:

<span class="todo-count"><%= remaining_items(@items) %> items left</span>

And replace it with the following code:

<span class="todo-count">
  <%= remaining_items(@items) %> <%= pluralise(@items) %> left
</span>

e.g: lib/app_web/views/item_view.ex#L47-L49

At the end of this step you will have a working pluralisation for the word item/items in the bottom left of the UI:

phx-todo-pluralise-demo

If you visit one of the TodoMVC examples, you will see that no <footer> is displayed when there are no items in the list: http://todomvc.com/examples/vanillajs

todo-mvc-vanilla-

At present our App shows the <footer> even if their are Zero items: 🤦

This is a visual distraction/clutter that creates unnecessary questions in the user's mind. Let's fix it!

Open your lib/app_web/views/item_view.ex file and add the following function definition unarchived_items/1:

def got_items?(items) do
  Enum.filter(items, fn i -> i.status < 2 end) |> Enum.count > 0
end

e.g: lib/app_web/views/item_view.ex#L52-L55

Now use got_items?/1 in the template.

Wrap the <footer> element in the following if statement:

<%= if got_items?(@items) do %>

<% end %>

e.g: lib/app_web/templates/item/index.html.eex#L45

The convention in Phoenix/Elixir (which came from Ruby/Rails) is to have a ? (question mark) in the name of functions that return a Boolean (true/false) result.

At the end of this step our <footer> element is hidden when there are no items:

phx-todo-footer-hidden

11.3 Route / to ItemController.index/2

The final piece of tidying up we can do is to change the Controller that gets invoked for the "homepage" (/) of our app. Currently when the person viewing the Todo App
visits http://localhost:4000/ they see the lib/app_web/templates/page/index.html.eex template:

page_template

This is the default Phoenix home page (minus the CSS Styles and images that we removed in step 3.4 above). It does not tell us anything about the actual app we have built, it doesn't even have a link to the Todo App! Let's fix it!

Open the lib/app_web/router.ex file and locate the line:

get "/", PageController, :index

Update the controller to: ItemController e.g:

get "/", ItemController, :index

e.g: lib/app_web/router.ex#L19

Now when you run your App you will see the todo list on the home page:

todo-app-on-homepage

Unfortunately, this update will "break" the page test. Run the tests and see:

1) test GET / (AppWeb.PageControllerTest)
     test/app_web/controllers/page_controller_test.exs:4
     Assertion with =~ failed
     code:  assert html_response(conn, 200) =~ "Welcome to Phoenix!"
     left:  "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n ..."

Given that we are no longer using the Page Controller, View, Template or Tests, we might as well delete them from our project!

git rm lib/app_web/controllers/page_controller.ex
git rm lib/app_web/templates/page/index.html.eex
git rm lib/app_web/views/page_view.ex
git rm test/app_web/controllers/page_controller_test.exs

Deleting files is good hygiene in any software project. Don't be afraid to do it, you can always recover files that are in your git history.

Re-run the tests:

mix test

You should see them pass now:

...........................

Finished in 0.5 seconds
27 tests, 0 failures

Given that our Phoenix Todo List App is 100% server rendered, older browsers will perform a full page refresh when an action (create/edit/toggle/delete) is performed. This will feel like a "blink" in the page and on really slow connections it will result in a temporary blank page! Obviously, that's horrible UX and is a big part of why Single Page Apps (SPAs) became popular; to avoid page refresh, use Turbolinks®!

Get the performance benefits of an SPA without the added complexity of a client-side JavaScript framework. When a link is clicked/tapped, Turbolinks automatically fetches the page, swaps in its <body>, and merges its <head>, all without incurring the cost of a full page load.

Add turbolinks package to /assets/package.json:

cd assets && npm install turbolinks --save

e.g: /assets/package.json#L18

In assets/app.js import Turbolinks and start it:

import Turbolinks from "turbolinks"
Turbolinks.start();

e.g: assets/js/app.js#L16-L17

That's it! Now when you deploy your server rendered Phoenix App, it will feel like an SPA! Seriously, try the Heroku demo again: phxtodo.herokuapp.com Feel that buttery-smooth page transition.

Deploy!

Deployment to Heroku takes a few minutes, but has a few "steps", therefore we have 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://phxtodo.herokuapp.com

todo-app-heroku-version

Done!

What Next?

If you found this example useful, please ⭐️ the GitHub repository so we (and others) know you liked it!

If you want to learn more Phoenix and the magic of LiveView, consider reading our beginner's tutorial: github.com/dwyl/phoenix-liveview-counter-tutorial

Thank you for learning with us! ☀️

<!--

Part 2: Authentication!

-->

Learning