Psst. It would be super cool if you could try the new Phoenix Boilerplate

Try now →

Tutorial

View on Github

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

formsliveviewphoenixtaggingtailwindtypeahead

In the previous tutorial, I set up the 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 that adding a tagging library written javascript.

Previous tutorial about tagging

Tutorial about LiveView and Tailwind typeahad

What we are building

In this tutorial I am building a tagging interface with typeahed that supports arrow keys and enter key in less than 170 lines of code. There is 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.

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: [],
      # THE FOLLOWING ONES REGARDS THE AUTOCOMPLETE
      # SEARCH BOX.
      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 to it when the component is connected so I dont do it twice. Might not be needed but I like to have a fresh copy og 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 where 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 surrounding div that acts as a fake form field. The idea is that it should blend in amongst other forms inputs

  2. The actual text inbut is an invisble 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 its probably not needed.

  4. There is a call to format_search_result 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 shoulf be able to see the for but it will now 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 somethig with either clicking on it or pressing enter
  4. What happens when you want to remove a tag

THis is the 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 Resault

Just to iterate. I am building a tagging interface with typeahed that supports arrow keys and enter key in less than 200 lines of code. There is zero lines of javascript except for LiveView and zero lines of css except for standard Tailwind.


Related Tutorials

Published 14 Feb

Improving LiveView UX with Phoenix Channels - Tagging part 3

channelsformsliveviewphoenixtagging

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

Typeahead with LiveView and Tailwind

formsliveviewphoenixtailwindtypeahead

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