loading...

LiveView TodoMVC - Part 2: params and hooks

amencarini profile image Alessandro Mencarini ・5 min read

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 lis with the phx-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 the li we double-clicked;
  • When one of the lis gets re-rendered, we want its edit 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 the editing 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 field editing to false

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!

Posted on by:

amencarini profile

Alessandro Mencarini

@amencarini

Living proof that not-very-smart engineers can be an asset. he/him

Discussion

markdown guide