phoenix-todo-list-tutorial alternatives and similar packages
Based on the "Examples and funny stuff" category.
Alternatively, view phoenix-todo-list-tutorial alternatives based on common mentions on social networks and blogs.
-
magnetissimo
Web application that indexes all popular torrent sites, and saves it to the local database. -
changelog.com
Changelog is news and podcast for developers. This is our open source platform. -
phoenix-chat-example
๐ฌ The Step-by-Step Beginners Tutorial for Building, Testing & Deploying a Chat app in Phoenix 1.7 [Latest] ๐ -
real world example app
Exemplary real world application built with Elixir + Phoenix -
phoenix-liveview-counter-tutorial
๐คฏ beginners tutorial building a real time counter in Phoenix 1.7 + LiveView 0.18 โก๏ธ -
phoenix-ecto-encryption-example
๐ A detailed example for how to encrypt data in an Elixir (Phoenix v1.7) App before inserting into a database using Ecto Types -
coderplanets.com
coderplanets.com API(GraphQL) server, build with elixir, phoenix, absinthe -
phoenix-flux-react
An experiment with Phoenix Channels, GenEvents, React and Flux. -
hello_phoenix
Application template for SPAs with Phoenix, React and Redux -
portal
A tutorial for shooting fault-tolerant portals for distributed data-transfer application in Elixir -
stranger
Chat anonymously with a randomly chosen stranger -
feedx
Generic feed adding social features to current applications. -
ex_iss
An elixir wrapper for Open Notify's International Space Station API. http://open-notify.org/ -
rubix
A very simple (and barely-functioning) Ruby runner for Elixir -
rollex
Elixir library using a Pratt Parser algorithm to calculate dice rolls.
Clean code begins in your IDE with SonarLint
Do you think we are missing an alternative of phoenix-todo-list-tutorial or a related project?
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.
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 -->
Try the Heroku 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 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 withCSS
in aPhoenix
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 [email protected]: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.
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:
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 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.
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.
Click the "Save" button and you will be redirected to the "show" page: /items/1
This is not an attractive User Experience (UX), but it works! Here is a list of items; a "Todo 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:
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
.
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.
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!
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";
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 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 /asssets/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.
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):
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:
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.
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:
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:
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:
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
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:
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:
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:
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
9. Footer Navigation
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.
9.4 Update the Footer in the index.html
Template
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:
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:
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:
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:
11.2 Hide Footer When There Are Zero Items
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/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:
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:
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:
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
11.4 Add Turbolinks to Eliminate Page Refresh
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
In assets/app.js
import Turbolinks and start it:
import Turbolinks from "turbolinks"
Turbolinks.start();
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
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
- 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