Tutorial
Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2
This post was updated 01 May - 2020
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.
- There is a surrounding div that acts as a fake form field. The idea is that it should blend in amongst other form inputs
- The actual text input is an invisible inline block.
- 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. - There is a call to
formatsearchresult
function that I have in theProductView
# 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 %>">×</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:
- What happens when you start typing in the search field
- What happens when you navigate the typeahead with the arrow keys
- What happens when you select something with either clicking on it or pressing enter
- 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.