Skip to content

Commit

Permalink
[Enhancement] Add sorting, pagination, and new attributes to sources …
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
kieraneglin authored Dec 13, 2024
1 parent e56f39a commit 53e106d
Show file tree
Hide file tree
Showing 16 changed files with 547 additions and 67 deletions.
6 changes: 5 additions & 1 deletion assets/js/alpine_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
23 changes: 0 additions & 23 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
}
}
}
})
Expand Down
27 changes: 21 additions & 6 deletions lib/pinchflat_web/components/custom_components/table_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,15 +26,28 @@ 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
~H"""
<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>
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions lib/pinchflat_web/components/custom_components/text_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
@moduledoc false
use Phoenix.Component

alias Pinchflat.Utils.NumberUtils
alias PinchflatWeb.CoreComponents

@doc """
Expand Down Expand Up @@ -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
18 changes: 0 additions & 18 deletions lib/pinchflat_web/controllers/pages/page_html.ex
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
108 changes: 108 additions & 0 deletions lib/pinchflat_web/controllers/sources/source_live/index_table_live.ex
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 53e106d

Please sign in to comment.