Tutorial

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

This post was updated 01 May - 2020

tagging typeahead forms liveview phoenix tailwind

In the previous tutorial, I set up the backend for being able to add tags to products. I have also written a tutorial about adding a LiveView and Tailwind typeahead. This will basically be very similar.

It will also be way simpler than adding a tagging library written in JavaScript.

Previous tutorial about tagging

Tutorial about LiveView and Tailwind typeahead

What we are building

In this tutorial, I am building a tagging interface with typeahead that supports arrow keys and enter key in less than 170 lines of code. There are zero lines of JavaScript except for LiveView and zero lines of CSS except for standard Tailwind.

STEP 1 - Add the LiveView component

First step is to create the new LiveView component. I will call it ProductTaggingLive. And just as in the typeahead tutorial, I will add some attributes regarding the actual suggestion box.

# lib/tutorial_web/live/product_tagging_live.ex

defmodule TutorialWeb.ProductTaggingLive do
  use Phoenix.LiveView

  alias Tutorial.{Repo, Products, Taggable}

  def mount(%{"id" => product_id} = _session, socket) do
    product = get_product(product_id, connected?(socket))

    assigns = [
      conn: socket,
      product: product,
      taggings: sorted(product.taggings),
      tags: [],
      search_results: [],
      search_phrase: "",
      current_focus: -1
    ]

    {:ok, assign(socket, assigns)}
  end

  def render(assigns) do
    TutorialWeb.ProductView.render("product_tagging.html", assigns)
  end
end

When I mount the component I only pass in the product_id. I will then load the product again from the database. But I will only do it when the component is connected so I don't do it twice. Might not be needed but I like to have a fresh copy of the product that I work on since it is in a separate process.

Note that I also preload existing tags so that I can populate the search field.

defp get_product(_, false), do: %{taggings: []}
defp get_product(product_id, _) do
  Products.get_product!(product_id) |> Repo.preload(:tags)
end

I also want the existing tags list, if the product has already been tagged, in the order they were added. So I add a sorting function here:

defp sorted(taggings), do: Enum.sort_by(taggings, &(&1.id))

STEP 2 - Add the template and markup

Next step is to create the template. I will just paste in the code but there are a few things to note.

  1. There is a surrounding div that acts as a fake form field. The idea is that it should blend in amongst other form inputs
  2. The actual text input is an invisible inline block.
  3. There is a debounce with: phx-debounce="500". I like to have it here but in this tutorial, I actually have loaded in all tags at once so it's probably not needed.
  4. There is a call to formatsearchresult function that I have in the ProductView
# lib/tutorial_web/templates/product/product_tagging.html.leex

<%= form_tag "#", [phx_change: :search, phx_submit: :submit] do %>
  <div class="py-2 px-3 bg-white border border-gray-400" phx-window-keydown="set-focus">
    <%= for tagging <- @taggings do %>
      <span class="inline-block text-xs bg-green-400 text-white py-1 px-2 mr-1 mb-1 rounded">
        <span><%= tagging.tag.name %></span>
        <a href="#" class="text-white hover:text-white" phx-click="delete" phx-value-tagging="<%= tagging.id %>">&times</a>
      </span>
    <% end %>
    <input
      type="text"
      class="inline-block text-sm focus:outline-none"
      name="search_phrase"
      value="<%= @search_phrase %>"
      phx-debounce="500"
      placeholder="Add tag"
    >
  </div>

  <%= if @search_results != [] do %>
    <div class="relative">
      <div class="absolute z-50 left-0 right-0 rounded border border-gray-100 shadow py-1 bg-white">
        <%= for {search_result, idx} <- Enum.with_index(@search_results) do %>
          <div class="cursor-pointer p-2 hover:bg-gray-200 focus:bg-gray-200 <%= if idx == @current_focus, do: "bg-gray-200" %>" phx-click="pick" phx-value-name="<%= search_result %>">
            <%= raw format_search_result(search_result, @search_phrase) %>
          </div>
        <% end %>
      </div>
    </div>
  <% end %>
<% end %>

And the view with the helper method:

# lib/tutorial_web/views/product_view.ex

defmodule TutorialWeb.ProductView do
  use TutorialWeb, :view

  def format_search_result(search_result, search_phrase) do
    split_at = String.length(search_phrase)
    {selected, rest} = String.split_at(search_result, split_at)

    "<strong>#{selected}</strong>#{rest}"
  end
end

When this is setup, we should now be able to mount the component. In this case, I want to have it in the show-template for products.

# lib/tutorial_web/templates/product/show.html.eex

<div class="mt-4">
  <%= live_render @conn, TutorialWeb.ProductTaggingLive, session: %{"id" => @product.id} %>
</div>

Now I should be able to see the form but it will not work to interact with it. I need to complete the logic in the LiveView component.

STEP 3 - Complete the component code

As I wrote above, I need to add some more functionality to the ProductTaggingLive component. It will be functionality about:

  1. What happens when you start typing in the search field
  2. What happens when you navigate the typeahead with the arrow keys
  3. What happens when you select something with either clicking on it or pressing enter
  4. What happens when you want to remove a tag

This is the functionality that is triggered when you type:

def handle_event("search", %{"search_phrase" => search_phrase}, socket) do
  tags = if socket.assigns.tags == [], do: Taggable.list_tags, else: socket.assigns.tags

  assigns = [
    tags: tags,
    search_results: search(tags, search_phrase),
    search_phrase: search_phrase
  ]

  {:noreply, assign(socket, assigns)}
end

This is the functionality that runs either when you click on something on the typeahead list or presses enter after typing a new tag.

def handle_event("pick", %{"name" => search_phrase}, socket) do
  product = socket.assigns.product
  taggings = add_tagging_to_product(product, search_phrase)

  assigns = [
    taggings: sorted(taggings),
    tags: [],
    search_results: [],
    search_phrase: ""
  ]

  {:noreply, assign(socket, assigns)}
end

And the functionality for clicking on the delete tag

def handle_event("delete", %{"tagging" => tagging_id}, socket) do
  taggings = delete_tagging_from_product(socket.assigns, tagging_id)

  assigns = [
    taggings: taggings
  ]

  {:noreply, assign(socket, assigns)}
end

So basically the entire file is now ~130 lines of code:

defmodule TutorialWeb.ProductTaggingLive do
  use Phoenix.LiveView

  alias Tutorial.{Repo, Products, Taggable}

  def mount(%{"id" => product_id} = _session, socket) do
    product = get_product(product_id, connected?(socket))

    assigns = [
      conn: socket,
      product: product,
      taggings: sorted(product.taggings),
      tags: [],
      search_results: [],
      search_phrase: "",
      current_focus: -1
    ]

    {:ok, assign(socket, assigns)}
  end

  def render(assigns) do
    TutorialWeb.ProductView.render("product_tagging.html", assigns)
  end

  def handle_event("search", %{"search_phrase" => search_phrase}, socket) do
    tags = if socket.assigns.tags == [], do: Taggable.list_tags, else: socket.assigns.tags

    assigns = [
      tags: tags,
      search_results: search(tags, search_phrase),
      search_phrase: search_phrase
    ]

    {:noreply, assign(socket, assigns)}
  end

  def handle_event("pick", %{"name" => search_phrase}, socket) do
    product = socket.assigns.product
    taggings = add_tagging_to_product(product, search_phrase)

    assigns = [
      taggings: sorted(taggings),
      tags: [],
      search_results: [],
      search_phrase: ""
    ]

    {:noreply, assign(socket, assigns)}
  end

  def handle_event("delete", %{"tagging" => tagging_id}, socket) do
    taggings = delete_tagging_from_product(socket.assigns, tagging_id)

    assigns = [
      taggings: taggings
    ]

    {:noreply, assign(socket, assigns)}
  end

  def handle_event("submit", _, socket), do: {:noreply, socket} # PREVENT FORM SUBMIT

  def handle_event("set-focus", %{"keyCode" => 38}, socket) do # UP
    current_focus =
      Enum.max([(socket.assigns.current_focus - 1), 0])
    {:noreply, assign(socket, current_focus: current_focus)}
  end

  def handle_event("set-focus", %{"keyCode" => 40}, socket) do # DOWN
    current_focus =
      Enum.min([(socket.assigns.current_focus + 1), (length(socket.assigns.search_results)-1)])
    {:noreply, assign(socket, current_focus: current_focus)}
  end

  def handle_event("set-focus", %{"keyCode" => 13}, socket) do # ENTER
    search_phrase =
      case Enum.at(socket.assigns.search_results, socket.assigns.current_focus) do
        "" <> search_phrase -> search_phrase # PICK ONE FROM THE DROP DOWN LIST
        _ -> socket.assigns.search_phrase # PICK ONE FROM INPUT FIELD
      end

    handle_event("pick", %{"name" => search_phrase}, socket)
  end

  # FALLBACK FOR NON RELATED KEY STROKES
  def handle_event("set-focus", _, socket), do: {:noreply, socket}

  defp search(_, ""), do: []
  defp search(tags, search_phrase) do
    tags
    |> Enum.map(& &1.name)
    |> Enum.sort()
    |> Enum.filter(& matches?(&1, search_phrase))
  end

  defp matches?(first, second) do
    String.starts_with?(
      String.downcase(first), String.downcase(second)
    )
  end

  defp get_product(_, false), do: %{taggings: []}
  defp get_product(product_id, _) do
    Products.get_product!(product_id) |> Repo.preload(:tags)
  end

  defp add_tagging_to_product(product, search_phrase) do
    Taggable.tag_product(product, %{tag: %{name: search_phrase}})
    %{taggings: taggings} = get_product(product.id, true)

    taggings
  end

  defp delete_tagging_from_product(%{product: product, taggings: taggings}, tagging_id) do
    taggings
    |> Enum.reject(fn tagging ->
      if "#{tagging.id}" == tagging_id do
        Taggable.delete_tag_from_product(product, tagging.tag)
        true
      else
        false
      end
    end)
  end

  defp sorted(taggings), do: Enum.sort_by(taggings, &(&1.id))
end

Final Result

Just to iterate, I am building a tagging interface with typeahead that supports arrow keys and enter key in less than 200 lines of code. There are zero lines of JavaScript except for LiveView and zero lines of CSS except for standard Tailwind.

Related Tutorials

Published 14 Feb - 2020
Updated 01 May - 2020

Improving LiveView UX with Phoenix Channels - Tagging part 3

In the previous tutorial I set up the tagging interface. It had however a small issue. If I added a tag, it didnt really refocus on the input so I n..

Published 27 Jan - 2020
Updated 01 May - 2020

Typeahead with LiveView and Tailwind

In this tutorial I want to show how easy it is to do an autocomplete or typeahead without any additional javascript!