Welcome back!
In Part 1 we built an almost fully functional version of the classic TodoMCV tutorial by simply using Phoenix LiveView.
There are a couple of bits of functionality that we haven't covered: the "Active, Completed, All" filter in the footer, and the ability to double-click an item to edit it.
Let's tackle them!
Passing parameters to a live view
To make the links in the footer actually do something, you'll need to use the live_path
function as their destination:
<ul class="filters">
<li>
<%= live_link "All",
to: Routes.live_path(@socket, TodoMVCWeb.MainLive, %{filter: "all"}),
class: selected_class(@filter, "all")
%>
</li>
<li>
<%= live_link "Active",
to: Routes.live_path(@socket, TodoMVCWeb.MainLive, %{filter: "active"}),
class: selected_class(@filter, "active")
%>
</li>
<li>
<%= live_link "Completed",
to: Routes.live_path(@socket, TodoMVCWeb.MainLive, %{filter: "completed"}),
class: selected_class(@filter, "completed")
%>
</li>
</ul>
Again, we're using a helper function to decide whether the link should have the selected
CSS class: we'll define it in the view, by using a pattern matching trick:
# lib/todo_mvc_web/views/main_view.ex
def selected_class(filter, filter), do: "selected"
def selected_class(_current_filter, _filter), do: ""
To break it down: if the first and second argument we're passing to selected_class/2
are the same string, it'll return the "selected"
string; an empty string will be returned otherwise.
We're now referring to a @filter
assign in the template, and we'll need to initialise it in the live view for the app to compile. Change the mount
function:
# lib/todo_mvc_web/live/main_live.ex
def mount(_params, socket) do
{:ok, assign(socket, todos: [], filter: "all")}
end
Now: how do we capture the value that's passed by query string? We need a handle_params
callback in the live view!
# lib/todo_mvc_web/live/main_live.ex
def handle_params(%{"filter" => filter}, _uri, socket) do
{:noreply, assign(socket, filter: filter)}
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
This will be enough to get the selected filter to be highlighted. But what about actually filtering the todos? We'll use the template and its view for this.
Filter the todos
Start by defining a helper function that given a todo and our current filter will say whether a todo should be visible:
def todo_visible?(_todo, "all"), do: true
def todo_visible?(%{state: state}, state), do: true
def todo_visible?(_, _), do: false
Again, we use some pattern matching magic: if the filter is set to "all"
, we're sure the todo needs to be visible; if the filter is set to "active"
and so is the state of the todo, show it; otherwise hide the todo.
Now, an elegant way to incorporate this change is to use a comprehension filter! In the template, change the start of the ul
like this:
<ul class="todo-list">
<%= for todo <- @todos, todo_visible?(todo, @filter) do %>
...
And that's it! You can now click around and only see the todos in the desired state.
Edit a todo
Phoenix LiveView does not (currently?) support binding an event to a double-click. To deal with this, we'll have to write a live view hook and... some JavaScript!
We'll start by defining the hook on the li
that hosts the todo:
<%= content_tag :li, class: todo_classes(todo), phx_hook: "Todo" do %>
Also add this at the bottom of the li
:
<form phx-change="change" phx-submit="change">
<input
class="edit"
name="title"
phx-value-todo-id="<%= todo.id %>"
phx-blur="stop-editing"
value="<%= todo.text %>"
>
</form>
We'll use the form to actually capture the changes to the todo text.
The function todo_classes
needs to be amended to allow for the editing
CSS class:
# lib/todo_mvc_web/views/main_view.ex
def todo_classes(todo) do
[
if(todo.editing, do: "editing"),
if(todo.state == "completed", do: "completed")
]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
end
Now, for the big reveal! Go to assets/js/app.js
and add this:
let Hooks = {}
Hooks.Todo = {
mounted() {
this.el.addEventListener("dblclick", e => {
const toggle = this.el.querySelector(".toggle")
this.pushEvent("edit", {
"todo-id": toggle.getAttribute("phx-value-todo-id")
})
})
},
updated() {
const edit = this.el.querySelector(".edit")
edit.focus()
edit.setSelectionRange(edit.value.length, edit.value.length);
}
}
What's happening here? We're asking LiveView to help us with two things:
- When one of our
li
s with thephx-hook="Todo"
attribute gets mounted, we want to set up a listener for double clicks that will send an"edit"
event down the wire, together with the todo id that we'll pick up from the checkbox of theli
we double-clicked; - When one of the
li
s gets re-rendered, we want itsedit
text input child to be in focus, and we want the cursor to be at the end of its contests.
(I'm not 100% happy with this, so if you have suggestions on how to improve this, please leave a comment!)
You'll also need to change the liveSocket
initialisation:
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks
})
We'll have to add a bunch of event handlers to the live view for this to work!
# lib/todo_mvc_web/live/main_live.ex
def handle_event("edit", %{"todo-id" => id}, socket) do
toggle_editing = fn
%Todo{id: ^id} = todo -> %{todo | editing: true}
todo -> todo
end
todos = socket.assigns[:todos] |> Enum.map(toggle_editing)
{:noreply, assign(socket, todos: todos)}
end
def handle_event("change", %{"title" => text}, socket) do
update_text = fn
%Todo{editing: true} = todo -> %{todo | text: text}
todo -> todo
end
todos = socket.assigns[:todos] |> Enum.map(update_text)
{:noreply, assign(socket, todos: todos)}
end
def handle_event("stop-editing", %{"todo-id" => id}, socket) do
toggle_editing = fn
%Todo{id: ^id} = todo -> %{todo | editing: false}
todo -> todo
end
todos = socket.assigns[:todos] |> Enum.map(toggle_editing)
{:noreply, assign(socket, todos: todos)}
end
That's quite a lot of code! Let's break it down a bit.
-
edit
is triggered by the JS hook and sets up the input text field to be visible by changing theediting
field of a todo -
change
is triggered by changes in the input text field and when the form gets submitted and sets the text of the todo to whatever is passed -
stop-editing
is set on blur (i.e.: when the input text field loses focus, because of the form being submitted or clicks in other areas of the page) and sets the todo fieldediting
tofalse
Phew! That was a lot of work!
Let's move for the last bit of UI.
Todo counter with pluralisation
Add this at the start of the footer:
<span class="todo-count">
<%= left_count_label(@todos) %>
</span>
Again, we'll do the heavy lifting in a view function!
# lib/todo_mvc_web/views/main_view.ex
def left_count_label(todos) do
ngettext(
"1 item left",
"%{count} items left",
Enum.count(todos, fn t -> t.state == "active" end)
)
end
For the pluralisation of the "items left" counter we rely on a gettext
facility. It's great we can leverage it even when we are not going to translate any locale!
Typespecs
To wrap things up, I'd suggest to add (Typespecs)[https://hexdocs.pm/elixir/typespecs.html] as an exercise. They're great as documentation and they can assist catching potential errors when running dialyzer (most likely through the brilliant (dialyxir)[https://github.com/jeremyjh/dialyxir] package).
Just to get you started, as an example, here's a specced version of handle_event
in the live view:
@spec handle_event(binary, map, Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
When you have multiple heads of a function, remember you only need a spec before the first head.
And that's all for the TodoMVC with LiveView! I hope you found this a useful way of starting out with this amazing library!
Top comments (0)