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.
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 Fly.io:
phxtodo.fly.dev
round-trip response times are sub 200ms for all interactions,
so it feels like a client-side rendered App.
A Todo list tutorial that shows a complete beginner how to build an app in Elixir/Phoenix from scratch.
Try it on Fly.io: phxtodo.fly.dev
Try the Fly.io version. Add a few items to the list and test the functionality.
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 Fly.io app on your Phone and see!
In this tutorial we are using the TodoMVC CSS to simplify our UI. This has several advantages the biggest being minimizing 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 withCSS
in aPhoenix
tutorial!
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! Asking questions on GitHub helps everyone to learn!
Please give us feedback! 🙏 Star the repo if you found it helpful. ⭐
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.
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:
Now that you have the finished example app
running on your localhost
,
let's build it from scratch
and understand all the steps.
When running the finished example app on localhost
,
if you want try the login
button,
you will need to get an AUTH_API_KEY
. [1 minute]
See:
Get your AUTH_API_KEY
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!
In your terminal,
create a new Phoenix app
using the following
mix
command:
mix phx.new app --no-dashboard --no-gettext --no-mailer
When prompted to install dependencies, type Y followed by Enter.
Note: those flags after the
app
name are just to avoid creating files we don't need for this simple example. See: hexdocs.pm/phoenix/Mix.Tasks.Phx.New
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:
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!
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/controllers/item_html/edit.html.heex
* creating lib/app_web/controllers/item_html/index.html.heex
* creating lib/app_web/controllers/item_html/new.html.heex
* creating lib/app_web/controllers/item_html/show.html.heex
* creating lib/app_web/controllers/item_html.ex
* creating test/app_web/controllers/item_controller_test.exs
* creating lib/app/todo/item.ex
* creating priv/repo/migrations/20221205102303_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
* creating test/support/fixtures/todo_fixtures.ex
* injecting test/support/fixtures/todo_fixtures.ex
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 todelete
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.
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
Now, as the terminal suggested,
run mix ecto.migrate
.
This will finish setting up the
database tables and run the
necessary migrations so
everything works properly!
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.
Click the "Save Item" button and you will be redirected to the "show" page: http://localhost:4000/items/1
This is not an attractive User Experience (UX),
but it works!
Here is a list of items - a "Todo List".
You can visit this by clicking
the Back to items
button or by
accessing the following URL
http://localhost:4000/items.
Let's improve the UX by using the TodoMVC HTML
and CSS
!
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:
Right-click on the source you want
(e.g: <section class="todoapp">
)
and select "Edit as HTML":
Once the HTML
for the <section>
is editable,
select it and copy it.
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 completeHTML
.
Open the lib/app_web/controllers/item_html/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/controllers/item_html/index.html.eex#L27-L73
If you attempt to run the app now
and visit
http://localhost:4000/items/
You will see this (without the TodoMVC CSS
):
That's obviously not what we want,
so let's get the TodoMVC CSS
and save it in our project!
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
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";
Open your lib/app_web/components/layouts/app.html.heex
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={~p"/assets/app.css"}/>
<script defer type="text/javascript" src={~p"/assets/app.js"}></script>
</head>
<body>
<main role="main" class="container">
<%= @inner_content %>
</main>
</body>
</html>
Before:
lib/app_web/components/layouts/app.html.eex
After:lib/app_web/components/layouts/app.html.heex
<%= @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 anyJavaScript
in this tutorial. We will achieve 100% feature parity with TodoMVC, without writing a line ofJS
. We don't "hate"JS
, in fact we have a "sister" tutorial that builds the same App inJS
: dwyl/javascript-todo-list-tutorial We just want to remind you that you don't need anyJS
to build a fully functional web application with great UX!
With the layout template saved,
the TodoMVC CSS file saved to /assets/css/todomvc-app.css
and the todomvc-app.css
imported in app.scss
,
your /items
page should now look like this:
So our Todo List is starting to look like TodoMVC, but it's still just a dummy list.
In order to render out item
data
in the TodoMVC template,
we are going to need to add
a few functions.
When we created the project
and generated the item
model,
a controller was created
(located in lib/app_web/controllers/item_controller.ex
)
and a component/view as well
(located in lib/app_web/controllers/item_html.ex
).
This Component/View
is what effectively
controls the rendering of the
contents inside the
lib/app_web/controllers/item_html
directory that we tinkered with prior.
We know that we need make changes to the UI, so we are going to add a few functions in this component (which is akin to the View part of the MVC paradigm).
This is our first chance to do a bit of Test Driven Development (TDD).
Create a new file with the path test/app_web/controllers/item_html_test.exs
.
Type the following code into the file:
defmodule AppWeb.ItemHTMLTest do
use AppWeb.ConnCase, async: true
alias AppWeb.ItemHTML
test "complete/1 returns completed if item.status == 1" do
assert ItemHTML.complete(%{status: 1}) == "completed"
end
test "complete/1 returns empty string if item.status == 0" do
assert ItemHTML.complete(%{status: 0}) == ""
end
end
e.g:
/test/app_web/controllers/item_html_test.exs
If you attempt to run this test file:
mix test test/app_web/controllers/item_html_test.exs
You will see the following error (because the function does not yet exist!):
** (UndefinedFunctionError) function AppWeb.ItemHTML.checked/1 is undefined or private
Open the
lib/app_web/controllers/item_html.ex
file
and write the functions to make the tests pass.
This is how we implemented the functions.
Your item_html.ex
file
now should look like the following.
defmodule AppWeb.ItemHTML do
use AppWeb, :html
embed_templates "item_html/*"
# 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
end
Re-run the tests and they should now pass:
mix test test/app_web/controllers/item_html_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/controllers/item_html/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">
<%= if item.status == 1 do %>
<input class="toggle" type="checkbox" checked/>
<% else %>
<input class="toggle" type="checkbox"/>
<% end %>
<label><%= item.text %></label>
<.link
class="destroy"
href={~p"/items/#{item}"}
method="delete"
>
</.link>
</div>
</li>
<% end %>
e.g:
lib/app_web/controllers/item_html/index.html.heex#L43-L53
With those two files saved,
if you run the app now: mix phx.server
and visit http://localhost:4000/items.
You will see the real items
you created in step 2.2 above:
Now that we have our items rendering in the TodoMVC layout, let's work on creating new items in the "single page app" style.
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
lib/app_web/controllers/item_html/new.html.heex
template (partial)
inside the
lib/app_web/controllers/item_html/index.html.heex
template. e.g:
Before we can do that, we need to tidy up the new.html.heex
template to remove the fields we don't need.
Let's open lib/app_web/controllers/item_html/new.html.heex
and simplify it to just the essential field :text
:
<.simple_form :let={f} for={@changeset} action={~p"/items"}>
<.input
field={{f, :text}}
type="text"
placeholder="what needs to be done?"
/>
<:actions>
<.button style="display:none">Save Item</.button>
</:actions>
</.simple_form>
Before:
/lib/app_web/controllers/item_html/new.html.heex
After:/lib/app_web/controllers/item_html/new.html.heex
We need to additionally
change the style of the <.input>
tag.
With Phoenix, inside the
lib/app_web/components/core_components.ex
file,
the styles are defined for pre-built components
(which is the case with <.input>
).
To change this so it uses the same style as TodoMVC, locate the following line.
def input(assigns) do
Change the class attribute
with the new-todo
class.
This function should look like the following.
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<input
type={@type}
name={@name}
id={@id || @name}
value={@value}
class={[
input_border(@errors),
"new-todo"
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
We also need to change the actions
styles
inside the simple_form
.
In the same file, search for def simple_form(assigns) do
and change it so it looks like so:
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
<div>
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions}>
<%= render_slot(action, f) %>
</div>
</div>
</.form>
"""
end
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:
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.
Given that we have removed two of the fields (:person_id
and :status
)
from the new.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.
In order to in-line the new item form (new.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.
Now that we have done all the preparation work,
the next step is to render the new.html.eex
(partial)
inside index.html.eex
template.
Open the lib/app_web/controllers/item_html/index.html.heex
file and locate the line:
<input class="new-todo" placeholder="What needs to be done?" autofocus="">
Replace it with this:
<%= new(Map.put(assigns, :action, ~p"/items/new")) %>
Let's break down what we just did.
We are embedding the new.html.heex
partial
inside the index.html.heex
file.
We are doing this by calling the
new/2
function inside item_controller.ex
.
This function pertains to the page in the URL items/new
and renders the new.html.heex
file.
Hence why we call this function to successfully embed 😄.
Before:
/lib/app_web/controllers/item_html/index.html.heex#L36
After:
/lib/app_web/controllers/item_html/index.html.heex#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.
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.
Open the lib/app_web/controllers/item_controller.ex
file
and locate the create
function.
Specifically the line:
|> redirect(to: ~p"/items/#{item}")
Update the line to:
|> redirect(to: ~p"/items/")
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:
The changes we've made to the new.html.heex
files
and the steps above have broken some of our automated tests.
We ought to fix that.
Run the tests:
mix test
You will see the following output:
Finished in 0.08 seconds (0.03s async, 0.05s sync)
23 tests, 3 failures
Open the test/app_web/controllers/item_controller_test.exs
file
and locate describe "new item"
and describe "create item"
.
Change these two to the following.
Replace the test:
describe "new item" do
test "renders form", %{conn: conn} do
conn = get(conn, ~p"/items/new")
assert html_response(conn, 200) =~ "what needs to be done?"
end
end
describe "create item" do
test "redirects to show when data is valid", %{conn: conn} do
conn = post(conn, ~p"/items", item: @create_attrs)
assert %{} = redirected_params(conn)
assert redirected_to(conn) == ~p"/items/"
end
test "errors when invalid attributes are passed", %{conn: conn} do
conn = post(conn, ~p"/items", item: @invalid_attrs)
assert html_response(conn, 200) =~ "can't be blank"
end
end
Updated code:
/test/app_web/controllers/item_controller_test.exs#L34-L55
If you re-run the tests mix test
the will now all pass again.
......................
Finished in 0.2 seconds (0.09s async, 0.1s sync)
22 tests, 0 failures
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/controllers/item_html_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 ItemHTML.remaining_items(items) == 2
end
test "remaining_items/1 returns 0 (zero) when no items are status==0" do
items = []
assert ItemHTML.remaining_items(items) == 0
end
e.g:
test/app_web/controllers/item_html_test.exs#L14-L26
These tests will fail because the ItemHTML.remaining_items/1
function does not exist.
Make the tests pass by adding the following code to
the lib/app_web/controllers/item_html.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/controllers/item_html#L15-L17
Now that the tests are passing,
use the remaining_items/1
in the index.html
template.
Open the lib/app_web/controllers/item_html/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 ItemHTML.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/controllers/item_html/index.html.eex#L60
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:
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!
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
.
We are going to need two functions in our controller:
toggle_status/1
toggles the status of an item e.g: 0 to 1 and 1 to 0.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.
We are going to make some changes here
so we can add tests to the functions we
mentioned prior.
We are going to import App.Todo
inside item_controller_test.exs
and fix create and attribute constants
to create mock items.
Make sure the beginning of the
file looks like so.
defmodule AppWeb.ItemControllerTest do
use AppWeb.ConnCase
alias App.Todo
import App.TodoFixtures
@create_attrs %{person_id: 42, status: 0, text: "some text"}
@public_create_attrs %{person_id: 0, status: 0, text: "some public text"}
@completed_attrs %{person_id: 42, status: 1, text: "some text completed"}
@public_completed_attrs %{person_id: 0, status: 1, text: "some public text completed"}
@update_attrs %{person_id: 43, status: 1, text: "some updated text"}
@invalid_attrs %{person_id: nil, status: nil, text: nil}
We are adding fixed Item
attributes
to later be used in tests.
We are specifying public
Item
s
because we will later add
authentication to this app.
After this, locate defp create_item()/1
function inside the same file.
Change it so it looks like so.
defp create_item(_) do
item = item_fixture(@create_attrs)
%{item: item}
end
We are going to be using this function
to create Item
objects
to use in the tests we are going to add.
Speaking of which, let's do that!
Add the following snippet to the file.
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, ~p'/items/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#L64-L82
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)})
conn
|> redirect(to: ~p"/items")
end
e.g:
/lib/app_web/controllers/item_controller.ex#L64-L76
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!
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
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/controllers/item_html/index.html.heex
file
and locate the line:
<%= if item.status == 1 do %>
...
<% else %>
...
<% end %>
Replace it with the following:
<%= if item.status == 1 do %>
<.link href={~p"/items/toggle/#{item.id}"}
class="toggle checked">
type="checkbox"
</.link>
<% else %>
<.link href={~p"/items/toggle/#{item.id}"}
type="checkbox"
class="toggle">
</.link>
<% end %>
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/controllers/item_html/index.html.heex#L40
After:/lib/app_web/controllers/item_html/index.html.heex#L47-L57
Unfortunately, <a>
tags (that are generated with <.link>
)
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#L8
And when you view the app, the Toggle functionality is working as expected:
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
.
If you "complete" or revert the operation,
the order of the todos might differ between
these operations.
To keep this consistent,
let's fetch all the todo
items in the same order.
Inside lib/app/todo.ex
,
change list_items/0
to the following.
def list_items do
query =
from(
i in Item,
select: i,
order_by: [asc: i.id]
)
Repo.all(query)
end
By fetching the todo
items and ordering them,
we guarantee the UX stays consistent!
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:
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/controllers/item_html/index.html.heex
file and locate the line:
<%= new(Map.put(assigns, :action, ~p"/items/new")) %>
Replace it with:
<%= if @editing.id do %>
<.link href={~p"/items"}
method="get"
class="new-todo">
Click here to create a new item!
</.link>
<% else %>
<%= new(Map.put(assigns, :action, ~p"/items/new")) %>
<% end %>
In here, we are checking if we are editing an item,
and rendering 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 new.html.heex
as before.
With this, if the user is editing an item,
he is able to "get out of editing mode"
by clicking on the link that is rendered.
e.g:
lib/app_web/controllers/item_html/index.html.heex#L30-L38
Next, still in the index.html.eex
file,
locate the line:
<%= for item <- @items do %>
Replace the entire <li>
tag
with the following code.
<li data-id={item.id} class={complete(item)}>
<%= if item.status == 1 do %>
<.link href={~p"/items/toggle/#{item.id}"}
class="toggle checked">
type="checkbox"
</.link>
<% else %>
<.link href={~p"/items/toggle/#{item.id}"}
type="checkbox"
class="toggle">
</.link>
<% end %>
<div class="view">
<%= if item.id == @editing.id do %>
<%= edit(
Map.put(assigns, :action, ~p"/items/#{item.id}/edit")
|> Map.put(:item, item)
) %>
<% else %>
<.link href={~p"/items/#{item}/edit"} class="dblclick">
<label><%= item.text %></label>
</.link>
<span></span> <!-- used for CSS Double Click -->
<% end %>
<.link
class="destroy"
href={~p"/items/#{item}"}
method="delete"
>
</.link>
</div>
</li>
e.g:
lib/app_web/controllers/item_html/index.html.heex#L46-L79
We have done a few things here.
We changed the toggle button outside the
<div class="view>
tag.
Additionally, we have changed the text
with a if else
block statements.
If the user is not editing,
a link (<a>
) is rendered which,
when clicked, allows the user to enter "edit" mode.
On the other hand, if the user is editing,
it renders the edit.html.heex
file.
Speaking of which, let's edit edit.html.heex
so it renders what we want:
a text field that, once Enter
is pressed,
edits the referring todo item.
<.simple_form :let={f} for={@changeset} method="put" action={~p"/items/#{@item}"}>
<.input
field={{f, :text}}
type="text"
placeholder="what needs to be done?"
class="new-todo"
/>
<:actions>
<.button
style="display: none;"
type="submit">
Save
</.button>
</:actions>
<!-- submit the form using the Return/Enter key -->
</.simple_form>
To enable the CSS double-click effect
to enter edit
mode,
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.css#L13-L32
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.css#L34-L71
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
Finally, we need to handle the form submission
to update an item (that is rendered in edit.html.heex
).
When we press Enter
, the update/2
handler is called
inside lib/app_web/controllers/item_controller.ex
.
We want to stay on the same page after updating the item.
So,change it so it looks like this.
def update(conn, %{"id" => id, "item" => item_params}) do
item = Todo.get_item!(id)
case Todo.update_item(item, item_params) do
{:ok, _item} ->
conn
|> redirect(to: ~p"/items/")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit, item: item, changeset: changeset)
end
end
Your item_controller.ex
file should now look like this:
lib/app_web/controllers/item_controller.ex
In our quest to build a Single Page App, we broke a few tests! That's OK. They're easy to fix.
Open the test/app_web/controllers/item_controller_test.exs
file and locate the test with the following text.
test "renders form for editing chosen item"
and change it so it looks like the following.
test "renders form for editing chosen item", %{conn: conn, item: item} do
conn = get(conn, ~p"/items/#{item}/edit")
assert html_response(conn, 200) =~ "Click here to create a new item"
end
When we enter the "edit timer mode",
we create <a>
a link to return to /items
,
as we have previously implemented.
This tag has the "Click here to create a new item" text,
which is what we are asserting.
e.g:
test/app_web/controllers/item_controller_test.exs#L37-L39
Next, locate the test with the following description:
describe "update item"
Update the block to the following piece of code.
describe "update item" do
setup [:create_item]
test "redirects when data is valid", %{conn: conn, item: item} do
conn = put(conn, ~p"/items/#{item}", item: @update_attrs)
assert redirected_to(conn) == ~p"/items/"
conn = get(conn, ~p"/items/")
assert html_response(conn, 200) =~ "some updated text"
end
test "errors when invalid attributes are passed", %{conn: conn, item: item} do
conn = put(conn, ~p"/items/#{item}", item: @invalid_attrs)
assert html_response(conn, 200) =~ "can't be blank"
end
end
e.g:
test/app_web/controllers/item_controller_test.exs#L67-L80
We've updated the paths the application redirects to
after updating an item.
Since we are building a single-page application,
that path pertains to the /items/
URL path.
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
Now that we have the toggle
and edit
features working,
we can finally remove the default Phoenix (table) layout
from the lib/app_web/controllers/item_html/index.html.heex
template.
Open the lib/app_web/controllers/item_html/index.html.eex
file
and remove all code before the line:
<section class="todoapp">
e.g:
lib/app_web/controllers/item_html/index.html.heex
Your app should now look like this:
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#L14
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
.
Before starting, let's add a unit test. We want to show filtered items according to the filter chosen.
Open test/app_web/controllers/item_controller_test.exs
and locate describe "index" do
.
In this block, add the following test.
It checks if the item is properly being shown
when the filter is changed.
test "lists items in filter", %{conn: conn} do
conn = post(conn, ~p"/items", item: @public_create_attrs)
# After creating item, navigate to 'active' filter page
conn = get(conn, ~p"/items/filter/active")
assert html_response(conn, 200) =~ @public_create_attrs.text
# Navigate to 'completed page'
conn = get(conn, ~p"/items/filter/completed")
assert !(html_response(conn, 200) =~ @public_create_attrs.text)
end
e.g:
test/app_web/controllers/item_controller_test.exs#L21-L32
Open the lib/app_web/router.ex
and
add the following route:
get "/items/filter/:filter", ItemController, :index
e.g:
/lib/app_web/router.ex#L23
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#L17-L22
Map.get(params, "filter", "all")
sets the default value of our filter
to "all"
so when index.html
is rendered, show "all" items.
In order to filter the items by their status,
we need to create a new function.
Open the lib/app_web/controllers/item_html.ex
file
and create the filter/2
function as follows:
def filter(items, str) do
case str do
"items" -> items
"active" -> Enum.filter(items, fn i -> i.status == 0 end)
"completed" -> Enum.filter(items, fn i -> i.status == 1 end)
_ -> items
end
end
e.g:
lib/app_web/controllers/item_html.ex#L19-L26
This will allow us to filter the items in the next step.
Use the filter/2
function to filter the items that are displayed.
Open the lib/app_web/controllers/item_html/index.html.heex
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/controllers/item_html/index.html.heex#L18
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>
<%= if @filter == "items" do %>
<a href="/items/filter/items" class="selected">
All
</a>
<% else %>
<a href="/items/filter/items">
All
</a>
<% end %>
</li>
<li>
<%= if @filter == "active" do %>
<a href="/items/filter/active" class='selected'>
Active
[<%= Enum.count(filter(@items, "active")) %>]
</a>
<% else %>
<a href="/items/filter/active">
Active
[<%= Enum.count(filter(@items, "active")) %>]
</a>
<% end %>
</li>
<li>
<%= if @filter == "completed" do %>
<a href="/items/filter/completed" class='selected'>
Completed
[<%= Enum.count(filter(@items, "completed")) %>]
</a>
<% else %>
<a href="/items/filter/completed">
Completed
[<%= Enum.count(filter(@items, "completed")) %>]
</a>
<% end %>
</li>
We are conditionally adding the selected
class
according to the @filter
assign value.
e.g:
/lib/app_web/controllers/item_html/index.html.heex#L62-L98
At the end of this step you will have a fully functioning footer filter:
We can quickly cover this function we added
with a small unit test.
Open test/app_web/controllers/item_html_test.exs
and add the following.
test "test filter function" do
items = [
%{text: "one", status: 0},
%{text: "two", status: 0},
%{text: "three", status: 1},
%{text: "four", status: 2},
%{text: "five", status: 2},
%{text: "six", status: 1},
]
assert length(ItemHTML.filter(items, "items")) == 4
assert length(ItemHTML.filter(items, "active")) == 2
assert length(ItemHTML.filter(items, "completed")) == 2
assert length(ItemHTML.filter(items, "any")) == 4
end
And you should be done with this feature 😀. Awesome job!
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 "/items/clear", ItemController, :clear_completed
Your scope "/"
should now look like the following:
scope "/", AppWeb do
pipe_through :browser
get "/", PageController, :home
get "/items/toggle/:id", ItemController, :toggle
get "/items/clear", ItemController, :clear_completed
get "/items/filter/:filter", ItemController, :index
resources "/items", ItemController
end
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#L87-L93
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/controllers/item_html/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="/items/clear">
Clear completed
[<%= Enum.count(filter(@items, "completed")) %>]
</a>
e.g:
lib/app_web/controllers/item_html/index.html.heex#L104-L107
The last thing we need to do is to
update the filter/2
function
inside lib/app_web/controllers/item_html.ex
.
Since status = 2
now pertains to an archived state,
we want to return anything that is not archived.
Change the filter/2
function so it looks like so.
def filter(items, str) do
case str do
"items" -> Enum.filter(items, fn i -> i.status !== 2 end)
"active" -> Enum.filter(items, fn i -> i.status == 0 end)
"completed" -> Enum.filter(items, fn i -> i.status == 1 end)
_ -> Enum.filter(items, fn i -> i.status !== 2 end)
end
end
At the end of this section your Todo List should have the "Clear completed" function working:
It's useful to have tests cover this feature.
Open test/app_web/controllers/item_controller_test.exs
.
Alongside the constants, on top of the file,
add the following line.
@completed_attrs %{person_id: 42, status: 1, text: "some text completed"}
We will use this to create an item that is already completed, so we can test the "clear completed" functionality.
Add the next lines to test the
clear_completed/2
function.
describe "clear completed" do
setup [:create_item]
test "clears the completed items", %{conn: conn, item: item} do
# Creating completed item
conn = post(conn, ~p"/items", item: @public_completed_attrs)
# Clearing completed items
conn = get(conn, ~p"/items/clear")
items = conn.assigns.items
[completed_item | _tail] = conn.assigns.items
assert conn.assigns.filter == "all"
assert completed_item.status == 2
end
test "clears the completed items in public (person_id=0)", %{conn: conn, item: item} do
# Creating completed item
conn = post(conn, ~p"/items", item: @public_completed_attrs)
# Clearing completed items
conn = get(conn, ~p"/items/clear")
items = conn.assigns.items
[completed_item | _tail] = conn.assigns.items
assert conn.assigns.filter == "all"
assert completed_item.status == 2
end
end
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!
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:
Open your test/app_web/controllers/item_html_test.exs
file
and add the following test:
test "pluralise/1 returns item for 1 item and items for < 1 <" do
assert ItemHTML.pluralise([%{text: "one", status: 0}]) == "item"
assert ItemHTML.pluralise([
%{text: "one", status: 0},
%{text: "two", status: 0}
]) == "items"
assert ItemHTML.pluralise([%{text: "one", status: 1}]) == "items"
end
e.g:
test/app_web/controllers/item_html_test.exs#L28-L35
This test will obviously fail because the
AppWeb.ItemHTML.pluralise/1
is undefined.
Let's make it pass!
Open your lib/app_web/controllers/item_html.ex
file
and add the following function definition for pluralise/1
:
# pluralise the word item when the number of items is greater/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/controllers/item_html.ex#L28-L35
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/controllers/item_html/index.html.heex
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/controllers/item_html/index.html.heex#L61
At the end of this step you will have a working pluralisation for the word item/items in the bottom left of the UI:
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
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/controllers/item_html.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/controllers/item_html.ex#L37-L39
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/controllers/item_html/index.html.heex#L58
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:
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/controllers/page_html/home.html.eex
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
.
get "/", ItemController, :index
e.g:
lib/app_web/router.ex#L20
Now when you run your App you will see the todo list on the home page:
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/controllers/page_html.ex
git rm lib/app_web/page_html/home.html.heex
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 Turbo!
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.
Luckily, adding Turbo
will require just a simple
copy and paste!
Check the unpkg files
to fetch the latest CDN package.
We now need to add the following line
to lib/app_web/components/layouts/app.html.heex
and lib/app_web/components/layouts/root.html.heex
.
<script src="https://unpkg.com/browse/@hotwired/turbo@7.2.4/dist/turbo.es2017-esm.js"></script>
This will install the UMD builds from Turbo
without us needing to install a package using npm
.
Neat, huh?
And that's it! Now when you deploy your server rendered Phoenix App, it will feel like an SPA! Try the Fly.io demo again: phxtodo.fly.dev Feel that buttery-smooth page transition.
Currently, our application occurs in the same page. However, there is a route that we don't use and is also aesthetically incompatible with the rest of our app.
If we check lib/app_web/controllers/item_controller.ex
,
you might notice the following function.
def show(conn, %{"id" => id}) do
item = Todo.get_item!(id)
render(conn, :show, item: item)
end
This serves the GET /items/:id
route.
We could do the same as we did with edit
and render index
.
However, let's do something different
so we learn a bit more about routes.
If we head on to router.ex
,
and locate the line:
resources "/items", ItemController
We can change it to this.
resources "/items", ItemController, except: [:show]
We are saying that we want to keep
all the routes in ItemController
except the one related to the show
action.
We can now safely delete it
from item_controller.ex
,
as we don't need it any more.
Your files should look like the following.
e.g:
/lib/router.ex#L19-L29
lib/app_web/controllers/item_controller.ex
Currently, the application allows anyone
to access it and manage todo items
.
Wouldn't it be great if
we added authentication so each person
could check their own list?
We created a dedicated authentication guide:
/auth.md
to help you set this up.
You will soon find out this is extremely easy 😀.
Deployment to Fly.io takes a few minutes, but has a few "steps", we suggest you follow the speed run guide: https://fly.io/docs/elixir/getting-started/
Once you have deployed you will will be able to view/use your app in any Web/Mobile Browser.
e.g: https://phxtodo.fly.dev
xs
Our Phoenix
server currently
only returns HTML
pages
that are server-side rendered.
This is already awesome
but we can make use of Phoenix
to extend its capabilities.
What if our server also responded
with JSON
?
You're in luck!
We've created small guide
for creating a REST API
:
api.md
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! ☀️
- Learn Elixir: https://github.com/dwyl/learn-elixir
- Learn Phoenix https://github.com/dwyl/learn-phoenix-framework
- Phoenix Chat Tutorial: https://github.com/dwyl/phoenix-chat-example
- Phoenix LiveView Counter Tutorial: https://github.com/dwyl/phoenix-liveview-counter-tutorial