Implementing Enhanced Tag Details and Sorting
These modifications are designed to enhance functionality and improve user experience.
We'll cover updates made to the Repo
module, changes in the Tag
schema,
alterations in the TagController
and StatsLive
modules,
and updates to LiveView files.
Implementing the toggle_sort_order
in Repo.ex
The toggle_sort_order
function in the Repo
module allows us to dynamically change the sorting order of our database queries.
This is useful for features where the user can sort items in ascending or descending order that will be used throughout the whole app where we need to sort it.
lib/app/repo.ex
def toggle_sort_order(:asc), do: :desc
def toggle_sort_order(:desc), do: :asc
If the current order is :asc (ascending), it changes to :desc (descending), and vice versa.
Extending the Tag
Model
Open lib/app/tag.ex
and add new fields to the Tag
schema.
field :last_used_at, :naive_datetime, virtual: true
field :items_count, :integer, virtual: true
field :total_time_logged, :integer, virtual: true
These fields are 'virtual', meaning they're not stored in the database but calculated on the fly.
The purposes of the fields are:
last_used_at
: the date a Tag was last used
items_count
: how many items are using the Tag
total_time_logged
: the total time that was logged with this particular Tag being used by a Item
We will add a new method that will query with these new fields on the same file.
Define list_person_tags_complete/3
:
def list_person_tags_complete(
person_id,
sort_column \\ :text,
sort_order \\ :asc
) do
sort_column =
if validate_sort_column(sort_column), do: sort_column, else: :text
Tag
|> where(person_id: ^person_id)
|> join(:left, [t], it in ItemTag, on: t.id == it.tag_id)
|> join(:left, [t, it], i in Item, on: i.id == it.item_id)
|> join(:left, [t, it, i], tm in Timer, on: tm.item_id == i.id)
|> group_by([t], t.id)
|> select([t, it, i, tm], %{
t
| last_used_at: max(it.inserted_at),
items_count: fragment("count(DISTINCT ?)", i.id),
total_time_logged:
sum(
coalesce(
fragment(
"EXTRACT(EPOCH FROM (? - ?))",
tm.stop,
tm.start
),
0
)
)
})
|> order_by(^get_order_by_keyword(sort_column, sort_order))
|> Repo.all()
end
And add these new methods at the end of the file:
defp validate_sort_column(column) do
Enum.member?(
[
:text,
:color,
:created_at,
:last_used_at,
:items_count,
:total_time_logged
],
column
)
end
defp get_order_by_keyword(sort_column, :asc) do
[asc: sort_column]
end
defp get_order_by_keyword(sort_column, :desc) do
[desc: sort_column]
end
These methods are used in the previous method to validate the columns that can be searched and to transform into keywords [asc: column] to work on the query.
Adding the new columns on the Tags
Page
First, we need to remove the index page from the tag_controller.ex
because we are going to include it on a new LiveView for Tags
.
This is needed because of the sorting events of the table.
So remove these next lines of code from the lib/app_web/controllers/tag_controller.ex
def index(conn, _params) do
person_id = conn.assigns[:person][:id] || 0
tags = Tag.list_person_tags(person_id)
render(conn, "index.html",
tags: tags,
lists: App.List.get_lists_for_person(person_id),
custom_list: false
)
end
Now, let's create the LiveView that will have the table for tags
and the redirections to all other pages on the TagController
.
Create a new file on lib/app_web/live/tags_live.ex
with the following content.
defmodule AppWeb.TagsLive do
use AppWeb, :live_view
alias App.{DateTimeHelper, Person, Tag, Repo}
# run authentication on mount
on_mount(AppWeb.AuthController)
@tags_topic "tags"
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: AppWeb.Endpoint.subscribe(@tags_topic)
person_id = Person.get_person_id(socket.assigns)
tags = Tag.list_person_tags_complete(person_id)
{:ok,
assign(socket,
tags: tags,
lists: App.List.get_lists_for_person(person_id),
custom_list: false,
sort_column: :text,
sort_order: :asc
)}
end
@impl true
def handle_event("sort", %{"key" => key}, socket) do
sort_column =
key
|> String.to_atom()
sort_order =
if socket.assigns.sort_column == sort_column do
Repo.toggle_sort_order(socket.assigns.sort_order)
else
:asc
end
person_id = Person.get_person_id(socket.assigns)
tags = Tag.list_person_tags_complete(person_id, sort_column, sort_order)
{:noreply,
assign(socket,
tags: tags,
sort_column: sort_column,
sort_order: sort_order
)}
end
def format_date(date) do
DateTimeHelper.format_date(date)
end
def format_seconds(seconds) do
DateTimeHelper.format_duration(seconds)
end
end
The whole code is similar to other LiveViews created on the project.
mount
This function is invoked when the LiveView component is mounted. It initializes the state of the LiveView.
on_mount(AppWeb.AuthController)
: This line ensures that authentication is run when the LiveView component mounts.- The
if connected?(socket)
block subscribes to a topic (@tags_topic) if the user is connected, enabling real-time updates. person_id
is retrieved to identify the current user.- tags are fetched using
Tag.list_person_tags_complete(person_id)
, which retrieves all tags associated with theperson_id
and is the method that we created previously. - The socket is assigned various values, such as tags, lists, custom_list, sort_column, and sort_order, setting up the initial state of the LiveView.
handle_event
This function is called when a "sort" event is triggered by user interaction on the UI.
sort_column
is set based on the event's key, determining which column to sort by.sort_order
is determined by the current state of sort_column and sort_order. If the sort_column is the same as the one already in the socket's assigns, the order is toggled usingRepo.toggle_sort_order
. Otherwise, it defaults to ascending (:asc).- Tags are then re-fetched with the new sort order and column, and the socket is updated with these new values.
- This dynamic sorting mechanism allows the user interface to update the display order of tags based on user interaction.
format_date(date)
Uses DateTimeHelper.format_date to format a given date.
format_seconds(seconds)
Uses DateTimeHelper.format_duration to format a duration in seconds into a more human-readable format.
Creating the LiveView HTML template
Create a new file on lib/app_web/live/tags_live.html.heex
that will handle the LiveView created in the previous section:
<main class="font-sans container mx-auto">
<div class="relative overflow-x-auto mt-12">
<h1 class="mb-2 text-xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
Listing Tags
</h1>
<.live_component
module={AppWeb.TableComponent}
id="tags_table_component"
rows={@tags}
sort_column={@sort_column}
sort_order={@sort_order}
highlight={fn _ -> false end}
>
<:column :let={tag} label="Name" key="text">
<td class="px-6 py-4 text-center" data-test-id={"text_#{tag.id}"}>
<%= tag.text %>
</td>
</:column>
<:column :let={tag} label="Color" key="color">
<td class="px-6 py-4 text-center" data-test-id={"color_#{tag.id}"}>
<span
style={"background-color:#{tag.color}"}
class="max-w-[144px] text-white font-bold py-1 px-2 rounded-full
overflow-hidden text-ellipsis whitespace-nowrap inline-block"
>
<%= tag.color %>
</span>
</td>
</:column>
<:column :let={tag} label="Created At" key="inserted_at">
<td class="px-6 py-4 text-center" data-test-id={"inserted_at_#{tag.id}"}>
<%= format_date(tag.inserted_at) %>
</td>
</:column>
<:column :let={tag} label="Latest" key="last_used_at">
<td
class="px-6 py-4 text-center"
data-test-id={"last_used_at_#{tag.id}"}
>
<%= if tag.last_used_at do %>
<%= format_date(tag.last_used_at) %>
<% else %>
-
<% end %>
</td>
</:column>
<:column :let={tag} label="Items Count" key="items_count">
<td class="px-6 py-4 text-center" data-test-id={"items_count_#{tag.id}"}>
<a href={~p"/?filter_by_tag=#{tag.text}"} class="underline">
<%= tag.items_count %>
</a>
</td>
</:column>
<:column :let={tag} label="Total Time Logged" key="total_time_logged">
<td
class="px-6 py-4 text-center"
data-test-id={"total_time_logged_#{tag.id}"}
>
<%= format_seconds(tag.total_time_logged) %>
</td>
</:column>
<:column :let={tag} label="Actions" key="actions">
<td class="px-6 py-4 text-center" data-test-id={"actions_#{tag.id}"}>
<%= link("Edit", to: Routes.tag_path(@socket, :edit, tag)) %>
<span class="text-red-500 ml-10">
<%= link("Delete",
to: Routes.tag_path(@socket, :delete, tag),
method: :delete,
data: [confirm: "Are you sure you want to delete this tag?"]
) %>
</span>
</td>
</:column>
</.live_component>
<.button
link_type="a"
to={Routes.tag_path(@socket, :new)}
label="Create Tag"
class="text-2xl text-center float-left rounded-md bg-green-600 hover:bg-green-700
my-2 mt-2 ml-2 px-4 py-2 font-semibold text-white shadow-sm"
/>
</div>
</main>
The structure is similar to the stats_live.html.heex
with the new columns for tags and the events for sorting.
And it's using the TableComponent
as well.
Adding the new page to the router
Open the lib/app_web/router.ex
and change the following line:
...
scope "/", AppWeb do
pipe_through [:browser, :authOptional]
live "/", AppLive
resources "/lists", ListController, except: [:show]
get "/logout", AuthController, :logout
live "/stats", StatsLive
+ live "/tags", TagsLive
resources "/tags", TagController, except: [:show]
end
...
After that, you can remove the lib/app_web/templates/tag/index.html.heex
file,
since we will use the Tags
LiveView for the tags page now.
Done! The Tags Page has the new columns and everything to be enhanced, congratulations!
It's just missing tests, let's add them:
test/app/repo_test.exs
defmodule App.RepoTest do
use ExUnit.Case
alias App.Repo
describe "toggle_sort_order/1" do
test "toggles :asc to :desc" do
assert Repo.toggle_sort_order(:asc) == :desc
end
test "toggles :desc to :asc" do
assert Repo.toggle_sort_order(:desc) == :asc
end
end
end
Add new test cases to the test/app/tag_test.exs
describe "list_person_tags/1" do
test "returns an empty list for a person with no tags" do
assert [] == Tag.list_person_tags(-1)
end
test "returns a single tag for a person with one tag" do
tag = add_test_tag(%{text: "TestTag", person_id: 1, color: "#FCA5A5"})
assert [tag] == Tag.list_person_tags(1)
end
test "returns tags in alphabetical order for a person with multiple tags" do
add_test_tag(%{text: "BTag", person_id: 2, color: "#FCA5A5"})
add_test_tag(%{text: "ATag", person_id: 2, color: "#FCA5A5"})
tags = Tag.list_person_tags(2)
assert length(tags) == 2
assert tags |> Enum.map(& &1.text) == ["ATag", "BTag"]
end
end
describe "list_person_tags_complete/3" do
test "returns detailed tag information for a given person" do
add_test_tag_with_details(%{person_id: 3, text: "DetailedTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(3)
assert length(tags) > 0
assert tags |> Enum.all?(&is_map(&1))
assert tags |> Enum.all?(&Map.has_key?(&1, :last_used_at))
assert tags |> Enum.all?(&Map.has_key?(&1, :items_count))
assert tags |> Enum.all?(&Map.has_key?(&1, :total_time_logged))
end
test "sorts tags based on specified sort_column and sort_order" do
add_test_tag_with_details(%{person_id: 4, text: "CTag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "ATag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "BTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(4, :text, :asc)
assert tags |> Enum.map(& &1.text) == ["ATag", "BTag", "CTag"]
end
test "sorts tags with desc sort_order" do
add_test_tag_with_details(%{person_id: 4, text: "CTag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "ATag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "BTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(4, :text, :desc)
assert tags |> Enum.map(& &1.text) == ["CTag", "BTag", "ATag"]
end
test "uses default sort_order when none are provided" do
add_test_tag_with_details(%{person_id: 5, text: "SingleTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(5, :text)
assert length(tags) == 1
end
test "uses default parameters when none are provided" do
add_test_tag_with_details(%{person_id: 5, text: "SingleTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(5)
assert length(tags) == 1
end
test "handles invalid column" do
add_test_tag_with_details(%{person_id: 6, text: "BTag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 6, text: "AnotherTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(6, :invalid_column)
assert length(tags) == 2
assert tags |> Enum.map(& &1.text) == ["AnotherTag", "BTag"]
end
end
defp add_test_tag(attrs) do
{:ok, tag} = Tag.create_tag(attrs)
tag
end
defp add_test_tag_with_details(attrs) do
tag = add_test_tag(attrs)
{:ok, %{model: item}} = Item.create_item(%{
person_id: tag.person_id,
status: 0,
text: "some item",
tags: [tag]
})
seconds_ago_date = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -10))
Timer.start(%{item_id: item.id, person_id: tag.person_id, start: seconds_ago_date})
Timer.stop_timer_for_item_id(item.id)
tag
end
Remove these next lines from the tag_controller_test.exs
since we don't have this page anymore:
describe "index" do
test "lists all tags", %{conn: conn} do
conn = get(conn, Routes.tag_path(conn, :index))
assert html_response(conn, 200) =~ "Listing Tags"
end
test "lists all tags and display logout button", %{conn: conn} do
conn =
conn
|> assign(:jwt, AuthPlug.Token.generate_jwt!(%{id: 1, picture: ""}))
|> get(Routes.tag_path(conn, :index))
assert html_response(conn, 200) =~ "logout"
end
end
test/app_web/live/tags_live_test.exs
defmodule AppWeb.TagsLiveTest do
use AppWeb.ConnCase, async: true
alias App.{Item, Timer, Tag}
import Phoenix.LiveViewTest
@person_id 0
test "disconnected and connected render", %{conn: conn} do
{:ok, page_live, disconnected_html} = live(conn, "/tags")
assert disconnected_html =~ "Tags"
assert render(page_live) =~ "Tags"
end
test "display tags on table", %{conn: conn} do
tag1 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag1", color: "#000000"})
tag2 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag2", color: "#000000"})
tag3 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag3", color: "#000000"})
{:ok, page_live, _html} = live(conn, "/tags")
assert render(page_live) =~ "Tags"
assert page_live
|> element("td[data-test-id=text_#{tag1.id}")
|> render() =~
"Tag1"
assert page_live
|> element("td[data-test-id=text_#{tag2.id}")
|> render() =~
"Tag2"
assert page_live
|> element("td[data-test-id=text_#{tag3.id}")
|> render() =~
"Tag3"
end
@tag tags: true
test "sorting column when clicked", %{conn: conn} do
add_test_tag_with_details(%{person_id: @person_id, text: "a", color: "#000000"})
add_test_tag_with_details(%{person_id: @person_id, text: "z", color: "#000000"})
{:ok, page_live, _html} = live(conn, "/tags")
# sort first time
result =
page_live |> element("th[phx-value-key=text]") |> render_click()
[first_element | _] = Floki.find(result, "td[data-test-id^=text_]")
assert first_element |> Floki.text() =~ "z"
# sort second time
result =
page_live |> element("th[phx-value-key=text]") |> render_click()
[first_element | _] = Floki.find(result, "td[data-test-id^=text_]")
assert first_element |> Floki.text() =~ "a"
end
defp add_test_tag_with_details(attrs) do
{:ok, tag} = Tag.create_tag(attrs)
{:ok, %{model: item}} = Item.create_item(%{
person_id: tag.person_id,
status: 0,
text: "some item",
tags: [tag]
})
seconds_ago_date = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -10))
Timer.start(%{item_id: item.id, person_id: tag.person_id, start: seconds_ago_date})
Timer.stop_timer_for_item_id(item.id)
tag
end
end
After these tests, you are ready to run the application and see your new changes!