[Enhancement] Add sorting, pagination, and new attributes to sources index table (#510)

* WIP - started improving handling of sorting for sources index table

* WIP - Added UI to table to indicate sort column and direction

* Refactored toggle liveview into a livecomponent

* Added sorting for all table attrs

* Added pagination to the sources table

* Added tests for updated liveviews and live components

* Add tests for new helper methods

* Added fancy new CSS to my sources table

* Added size to sources table

* Adds relative div to ensure that sorting arrow doesn't run away

* Fixed da tests
This commit is contained in:
Kieran 2024-12-13 09:49:00 -08:00 committed by GitHub
parent e56f39a158
commit 53e106dac2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 547 additions and 67 deletions

View file

@ -40,5 +40,9 @@ window.dispatchFor = (elementOrId, eventName, detail = {}) => {
const element =
typeof elementOrId === 'string' ? document.getElementById(elementOrId) : elementOrId
element.dispatchEvent(new CustomEvent(eventName, { detail }))
// This is needed to ensure the DOM has updated before dispatching the event.
// Doing so ensures that the latest DOM state is what's sent to the server
setTimeout(() => {
element.dispatchEvent(new Event(eventName, { bubbles: true, detail }))
}, 0)
}

View file

@ -47,29 +47,6 @@ let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
}
})
}
},
'formless-input': {
mounted() {
const subscribedEvents = this.el.dataset.subscribe.split(' ')
const eventName = this.el.dataset.eventName || ''
const identifier = this.el.dataset.identifier || ''
subscribedEvents.forEach((domEvent) => {
this.el.addEventListener(domEvent, () => {
// This ensures that the event is pushed to the server after the input value has been updated
// so that the server has the most up-to-date value
setTimeout(() => {
this.pushEvent('formless-input', {
value: this.el.value,
id: identifier,
event: eventName,
dom_id: this.el.id,
dom_event: domEvent
})
}, 0)
})
})
}
}
}
})

View file

@ -16,6 +16,8 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
"""
attr :rows, :list, required: true
attr :table_class, :string, default: ""
attr :sort_key, :string, default: nil
attr :sort_direction, :string, default: nil
attr :row_item, :any,
default: &Function.identity/1,
@ -24,6 +26,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
slot :col, required: true do
attr :label, :string
attr :class, :string
attr :sort_key, :string
end
def table(assigns) do
@ -31,8 +34,20 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<table class={["w-full table-auto bg-boxdark", @table_class]}>
<thead>
<tr class="text-left bg-meta-4">
<th :for={col <- @col} class="px-4 py-4 font-medium text-white xl:pl-11">
{col[:label]}
<th
:for={col <- @col}
class={["px-4 py-4 font-medium text-white xl:pl-11", col[:sort_key] && "cursor-pointer"]}
phx-click={col[:sort_key] && "sort_update"}
phx-value-sort_key={col[:sort_key]}
>
<div class="relative">
{col[:label]}
<.icon
:if={to_string(@sort_key) == col[:sort_key]}
name={if @sort_direction == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="w-3 h-3 mt-2 ml-1 absolute"
/>
</div>
</th>
</tr>
</thead>
@ -70,9 +85,9 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<li>
<span
class={[
"flex h-8 w-8 items-center justify-center rounded",
"pagination-prev h-8 w-8 items-center justify-center rounded",
@page_number != 1 && "cursor-pointer hover:bg-primary hover:text-white",
@page_number == 1 && "cursor-not-allowed"
@page_number <= 1 && "cursor-not-allowed"
]}
phx-click={@page_number != 1 && "page_change"}
phx-value-direction="dec"
@ -88,9 +103,9 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<li>
<span
class={[
"flex h-8 w-8 items-center justify-center rounded",
"pagination-next flex h-8 w-8 items-center justify-center rounded",
@page_number != @total_pages && "cursor-pointer hover:bg-primary hover:text-white",
@page_number == @total_pages && "cursor-not-allowed"
@page_number >= @total_pages && "cursor-not-allowed"
]}
phx-click={@page_number != @total_pages && "page_change"}
phx-value-direction="inc"

View file

@ -2,6 +2,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
@moduledoc false
use Phoenix.Component
alias Pinchflat.Utils.NumberUtils
alias PinchflatWeb.CoreComponents
@doc """
@ -125,4 +126,24 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
{@word}{if @count == 1, do: "", else: @suffix}
"""
end
@doc """
Renders a human-readable byte size
"""
attr :byte_size, :integer, required: true
def readable_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.byte_size, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> {@suffix}
"""
end
end

View file

@ -1,23 +1,5 @@
defmodule PinchflatWeb.Pages.PageHTML do
use PinchflatWeb, :html
alias Pinchflat.Utils.NumberUtils
embed_templates "page_html/*"
attr :media_filesize, :integer, required: true
def readable_media_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.media_filesize, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> {@suffix}
"""
end
end

View file

@ -33,7 +33,7 @@
<span class="flex flex-col items-center py-2">
<span class="text-md font-medium">Library Size</span>
<h4 class="text-title-md font-bold text-white">
<.readable_media_filesize media_filesize={@media_item_size} />
<.readable_filesize byte_size={@media_item_size} />
</h4>
</span>
</div>

View file

@ -11,8 +11,12 @@
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 min-w-max">
{live_render(@conn, PinchflatWeb.Sources.IndexTableLive)}
</div>
{live_render(@conn, PinchflatWeb.Sources.SourceLive.IndexTableLive,
session: %{
"initial_sort_key" => :custom_name,
"initial_sort_direction" => :asc,
"results_per_page" => 10
}
)}
</div>
</div>

View file

@ -0,0 +1,108 @@
defmodule PinchflatWeb.Sources.SourceLive.IndexTableLive do
use PinchflatWeb, :live_view
use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery
import PinchflatWeb.Helpers.SortingHelpers
import PinchflatWeb.Helpers.PaginationHelpers
alias Pinchflat.Repo
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
def mount(_params, session, socket) do
limit = session["results_per_page"]
initial_params =
Map.merge(
%{
sort_key: session["initial_sort_key"],
sort_direction: session["initial_sort_direction"]
},
get_pagination_attributes(sources_query(), 1, limit)
)
socket
|> assign(initial_params)
|> set_sources()
|> then(&{:ok, &1})
end
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
new_page = update_page_number(assigns.page, direction, assigns.total_pages)
socket
|> assign(get_pagination_attributes(sources_query(), new_page, assigns.limit))
|> set_sources()
|> then(&{:noreply, &1})
end
def handle_event("sort_update", %{"sort_key" => sort_key}, %{assigns: assigns} = socket) do
new_sort_key = String.to_existing_atom(sort_key)
new_params = %{
sort_key: new_sort_key,
sort_direction: get_sort_direction(assigns.sort_key, new_sort_key, assigns.sort_direction)
}
socket
|> assign(new_params)
|> set_sources()
|> then(&{:noreply, &1})
end
defp sort_attr(:pending_count), do: dynamic([s, mp, dl, pe], pe.pending_count)
defp sort_attr(:downloaded_count), do: dynamic([s, mp, dl], dl.downloaded_count)
defp sort_attr(:media_size_bytes), do: dynamic([s, mp, dl], dl.media_size_bytes)
defp sort_attr(:media_profile_name), do: dynamic([s, mp], mp.name)
defp sort_attr(:custom_name), do: dynamic([s], s.custom_name)
defp sort_attr(:enabled), do: dynamic([s], s.enabled)
defp set_sources(%{assigns: assigns} = socket) do
sources =
sources_query()
|> order_by(^[{assigns.sort_direction, sort_attr(assigns.sort_key)}, asc: :id])
|> limit(^assigns.limit)
|> offset(^assigns.offset)
|> Repo.all()
assign(socket, %{sources: sources})
end
defp sources_query do
downloaded_subquery =
from(
m in MediaItem,
select: %{downloaded_count: count(m.id), source_id: m.source_id, media_size_bytes: sum(m.media_size_bytes)},
where: ^MediaQuery.downloaded(),
group_by: m.source_id
)
pending_subquery =
from(
m in MediaItem,
inner_join: s in assoc(m, :source),
inner_join: mp in assoc(s, :media_profile),
select: %{pending_count: count(m.id), source_id: m.source_id},
where: ^MediaQuery.pending(),
group_by: m.source_id
)
from s in Source,
as: :source,
inner_join: mp in assoc(s, :media_profile),
left_join: d in subquery(downloaded_subquery),
on: d.source_id == s.id,
left_join: p in subquery(pending_subquery),
on: p.source_id == s.id,
on: d.source_id == s.id,
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
preload: [media_profile: mp],
select: map(s, ^Source.__schema__(:fields)),
select_merge: %{
downloaded_count: coalesce(d.downloaded_count, 0),
pending_count: coalesce(p.pending_count, 0),
media_size_bytes: coalesce(d.media_size_bytes, 0)
}
end
end

View file

@ -0,0 +1,41 @@
<div class="flex flex-col min-w-max">
<.table rows={@sources} table_class="text-white" sort_key={@sort_key} sort_direction={@sort_direction}>
<:col :let={source} label="Name" sort_key="custom_name" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{source.id}"}>
{source.custom_name}
</.subtle_link>
</:col>
<:col :let={source} label="Pending" sort_key="pending_count">
<.subtle_link href={~p"/sources/#{source.id}/#tab-pending"}>
<.localized_number number={source.pending_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Downloaded" sort_key="downloaded_count">
<.subtle_link href={~p"/sources/#{source.id}/#tab-downloaded"}>
<.localized_number number={source.downloaded_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Size" sort_key="media_size_bytes">
<.readable_filesize byte_size={source.media_size_bytes} />
</:col>
<:col :let={source} label="Media Profile" sort_key="media_profile_name">
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
{source.media_profile.name}
</.subtle_link>
</:col>
<:col :let={source} label="Enabled?" sort_key="enabled">
<.live_component
module={PinchflatWeb.Sources.SourceLive.SourceEnableToggle}
source={source}
id={"source_#{source.id}_enabled"}
/>
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
</:col>
</.table>
<section class="flex justify-center my-5">
<.live_pagination_controls page_number={@page} total_pages={@total_pages} />
</section>
</div>

View file

@ -0,0 +1,35 @@
defmodule PinchflatWeb.Sources.SourceLive.SourceEnableToggle do
use PinchflatWeb, :live_component
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
def render(assigns) do
~H"""
<div>
<.form :let={f} for={@form} phx-change="update" phx-target={@myself} class="enabled_toggle_form">
<.input id={"source_#{@source_id}_enabled_input"} field={f[:enabled]} type="toggle" />
</.form>
</div>
"""
end
def update(assigns, socket) do
initial_data = %{
source_id: assigns.source.id,
form: Sources.change_source(%Source{}, assigns.source)
}
socket
|> assign(initial_data)
|> then(&{:ok, &1})
end
def handle_event("update", %{"source" => source_params}, %{assigns: assigns} = socket) do
assigns.source_id
|> Sources.get_source!()
|> Sources.update_source(source_params)
{:noreply, socket}
end
end

View file

@ -0,0 +1,45 @@
defmodule PinchflatWeb.Helpers.PaginationHelpers do
@moduledoc """
Methods for working with pagination, usually in the context of LiveViews or LiveComponents.
These methods are fairly simple, but they're commonly repeated across different Live entities
"""
alias Pinchflat.Repo
alias Pinchflat.Utils.NumberUtils
@doc """
Given a query, a page number, and a number of records per page, returns a map of pagination attributes.
Returns map()
"""
def get_pagination_attributes(query, page, records_per_page) do
total_record_count = Repo.aggregate(query, :count, :id)
total_pages = max(ceil(total_record_count / records_per_page), 1)
clamped_page = NumberUtils.clamp(page, 1, total_pages)
%{
page: clamped_page,
total_pages: total_pages,
total_record_count: total_record_count,
limit: records_per_page,
offset: (clamped_page - 1) * records_per_page
}
end
@doc """
Given a current page number, a direction to move in, and the total number of pages, returns the updated page number.
The updated page number is clamped to the range [1, total_pages].
Returns integer()
"""
def update_page_number(current_page, direction, total_pages) do
updated_page =
case to_string(direction) do
"inc" -> current_page + 1
"dec" -> current_page - 1
end
NumberUtils.clamp(updated_page, 1, total_pages)
end
end

View file

@ -0,0 +1,20 @@
defmodule PinchflatWeb.Helpers.SortingHelpers do
@moduledoc """
Methods for working with sorting, usually in the context of LiveViews or LiveComponents.
These methods are fairly simple, but they're commonly repeated across different Live entities
"""
@doc """
Given the old sort attribute, the new sort attribute, and the old sort direction, returns the new sort direction.
Returns :asc | :desc
"""
def get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) do
case {new_sort_attr, old_sort_direction} do
{^old_sort_attr, :desc} -> :asc
{^old_sort_attr, _} -> :desc
_ -> :asc
end
end
end

View file

@ -1,4 +1,4 @@
defmodule PinchflatWeb.Sources.IndexTableLiveTest do
defmodule PinchflatWeb.Sources.SourceLive.IndexTableLiveTest do
use PinchflatWeb.ConnCase
import Phoenix.LiveViewTest
@ -6,13 +6,13 @@ defmodule PinchflatWeb.Sources.IndexTableLiveTest do
import Pinchflat.ProfilesFixtures
alias Pinchflat.Sources.Source
alias PinchflatWeb.Sources.IndexTableLive
alias PinchflatWeb.Sources.SourceLive.IndexTableLive
describe "initial rendering" do
test "lists all sources", %{conn: conn} do
source = source_fixture()
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
{:ok, _view, html} = live_isolated(conn, IndexTableLive, session: create_session())
assert html =~ source.custom_name
end
@ -20,7 +20,7 @@ defmodule PinchflatWeb.Sources.IndexTableLiveTest do
test "omits sources that have marked_for_deletion_at set", %{conn: conn} do
source = source_fixture(marked_for_deletion_at: DateTime.utc_now())
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
{:ok, _view, html} = live_isolated(conn, IndexTableLive, session: create_session())
refute html =~ source.custom_name
end
@ -29,27 +29,102 @@ defmodule PinchflatWeb.Sources.IndexTableLiveTest do
media_profile = media_profile_fixture(marked_for_deletion_at: DateTime.utc_now())
source = source_fixture(media_profile_id: media_profile.id)
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
{:ok, _view, html} = live_isolated(conn, IndexTableLive, session: create_session())
refute html =~ source.custom_name
end
end
describe "when a source is enabled or disabled" do
describe "when testing sorting" do
test "sorts by the custom_name by default", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_B")
source2 = source_fixture(custom_name: "Source_A")
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
assert render_element(view, "tbody tr:first-child") =~ source2.custom_name
assert render_element(view, "tbody tr:last-child") =~ source1.custom_name
end
test "clicking the row will change the sort direction", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_B")
source2 = source_fixture(custom_name: "Source_A")
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
# Click the row to change the sort direction
click_element(view, "th", "Name")
assert render_element(view, "tbody tr:first-child") =~ source1.custom_name
assert render_element(view, "tbody tr:last-child") =~ source2.custom_name
end
test "clicking a different row will sort by that attribute", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_A", enabled: true)
source2 = source_fixture(custom_name: "Source_A", enabled: false)
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
# Click the row to change the sort field
click_element(view, "th", "Enabled?")
assert render_element(view, "tbody tr:first-child") =~ source2.custom_name
assert render_element(view, "tbody tr:last-child") =~ source1.custom_name
# Click the row to again change the sort direcation
click_element(view, "th", "Enabled?")
assert render_element(view, "tbody tr:first-child") =~ source1.custom_name
assert render_element(view, "tbody tr:last-child") =~ source2.custom_name
end
end
describe "when testing pagination" do
test "moving to the next page loads new records", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_A")
source2 = source_fixture(custom_name: "Source_B")
session = Map.merge(create_session(), %{"results_per_page" => 1})
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: session)
assert render_element(view, "tbody") =~ source1.custom_name
refute render_element(view, "tbody") =~ source2.custom_name
click_element(view, "span.pagination-next")
refute render_element(view, "tbody") =~ source1.custom_name
assert render_element(view, "tbody") =~ source2.custom_name
end
end
describe "when testing the enable toggle" do
test "updates the source's enabled status", %{conn: conn} do
source = source_fixture(enabled: true)
{:ok, view, _html} = live_isolated(conn, IndexTableLive)
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
params = %{
"event" => "toggle_enabled",
"id" => source.id,
"value" => "false"
}
# Send an event to the server directly
render_change(view, "formless-input", params)
view
|> element(".enabled_toggle_form")
|> render_change(%{source: %{"enabled" => false}})
assert %{enabled: false} = Repo.get!(Source, source.id)
end
end
defp click_element(view, selector, text_filter \\ nil) do
view
|> element(selector, text_filter)
|> render_click()
end
defp render_element(view, selector) do
view
|> element(selector)
|> render()
end
defp create_session do
%{
"initial_sort_key" => :custom_name,
"initial_sort_direction" => :asc,
"results_per_page" => 10
}
end
end

View file

@ -0,0 +1,26 @@
defmodule PinchflatWeb.Sources.SourceLive.SourceEnableToggleTest do
use PinchflatWeb.ConnCase
import Phoenix.LiveViewTest
alias PinchflatWeb.Sources.SourceLive.SourceEnableToggle
describe "initial rendering" do
test "renders a toggle in the on position if the source is enabled" do
source = %{id: 1, enabled: true}
html = render_component(SourceEnableToggle, %{id: :foo, source: source})
# This is checking the Alpine attrs which is a good-enough proxy for the toggle position
assert html =~ "{ enabled: true }"
end
test "renders a toggle in the off position if the source is disabled" do
source = %{id: 1, enabled: false}
html = render_component(SourceEnableToggle, %{id: :foo, source: source})
assert html =~ "{ enabled: false }"
end
end
end

View file

@ -0,0 +1,96 @@
defmodule PinchflatWeb.Helpers.PaginationHelpersTest do
use Pinchflat.DataCase
import Pinchflat.SourcesFixtures
alias Pinchflat.Sources.Source
alias PinchflatWeb.Helpers.PaginationHelpers
describe "get_pagination_attributes/3" do
test "returns the correct pagination attributes" do
source_fixture()
query = from(s in Source, select: s.id)
page = 1
records_per_page = 10
pagination_attributes = PaginationHelpers.get_pagination_attributes(query, page, records_per_page)
assert pagination_attributes.page == 1
assert pagination_attributes.total_pages == 1
assert pagination_attributes.total_record_count == 1
assert pagination_attributes.limit == 10
assert pagination_attributes.offset == 0
end
test "returns the correct pagination attributes when there are multiple pages" do
source_fixture()
source_fixture()
query = from(s in Source, select: s.id)
page = 1
records_per_page = 1
pagination_attributes = PaginationHelpers.get_pagination_attributes(query, page, records_per_page)
assert pagination_attributes.page == 1
assert pagination_attributes.total_pages == 2
assert pagination_attributes.total_record_count == 2
assert pagination_attributes.limit == 1
assert pagination_attributes.offset == 0
end
test "returns the correct attributes when on a page other than the first" do
source_fixture()
source_fixture()
query = from(s in Source, select: s.id)
page = 2
records_per_page = 1
pagination_attributes = PaginationHelpers.get_pagination_attributes(query, page, records_per_page)
assert pagination_attributes.page == 2
assert pagination_attributes.total_pages == 2
assert pagination_attributes.total_record_count == 2
assert pagination_attributes.limit == 1
assert pagination_attributes.offset == 1
end
end
describe "update_page_number/3" do
test "increments the page number" do
current_page = 1
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :inc, total_pages)
assert updated_page == 2
end
test "decrements the page number" do
current_page = 2
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :dec, total_pages)
assert updated_page == 1
end
test "doesn't overflow the page number" do
current_page = 2
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :inc, total_pages)
assert updated_page == 2
end
test "doesn't underflow the page number" do
current_page = 1
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :dec, total_pages)
assert updated_page == 1
end
end
end

View file

@ -0,0 +1,31 @@
defmodule PinchflatWeb.Helpers.SortingHelpersTest do
use Pinchflat.DataCase
alias PinchflatWeb.Helpers.SortingHelpers
describe "get_sort_direction/3" do
test "returns the correct sort direction when the new sort attribute is the same as the old sort attribute" do
old_sort_attr = "name"
new_sort_attr = "name"
old_sort_direction = :desc
assert SortingHelpers.get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) == :asc
end
test "returns the correct sort direction when the new sort attribute is the same as the old sort attribute in the other direction" do
old_sort_attr = "name"
new_sort_attr = "name"
old_sort_direction = :asc
assert SortingHelpers.get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) == :desc
end
test "returns the correct sort direction when the new sort attribute is different from the old sort attribute" do
old_sort_attr = "name"
new_sort_attr = "date"
old_sort_direction = :asc
assert SortingHelpers.get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) == :asc
end
end
end