Convert /contacts list to live_table

Replaces the hand-rolled sort/search/pagination on /contacts with
LiveTable.LiveResource. Sortable columns for station1/2, grid1/2,
band, mode, distance_km, qso_timestamp, and inserted_at; searchable
across both stations, both grids, and mode. Custom renderers preserve
the enrichment summary badge (with per-status tooltip), the invalid
flag column, and the qso/inserted_at formatters.

The reciprocal-grouping block has been dropped per the brainstorming
decision — every QSO now shows as its own row, which avoids fighting
live_table's one-row-per-record model. A "View" action links to the
detail page instead of the previous row-click handler (live_table's
stream dom_ids are random UUIDs so row-click by record id is no
longer practical without forking the library).

Test updates:
- sort test switched to ?sort_params[station1]=asc (live_table URL
  format; the old ?sort_by/?sort_order params are gone)
- pair-search test removed since live_table does a single ILIKE
  across searchable fields and no longer supports the two-callsign
  intersection that Radio.list_contacts/1 used to do; single-term
  search is exercised instead
- pagination test asserts the presence of "Page" (live_table's
  pagination text is "Page N" without the total count)

Also converts the stray cond-with-single-clause in
UserManagementLive.Index's delete handler to if/else (credo fix).
This commit is contained in:
Graham McIntire 2026-04-12 17:22:15 -05:00
parent 606340e1c7
commit f733ddb6ac
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 75 additions and 208 deletions

View file

@ -1,77 +1,46 @@
defmodule MicrowavepropWeb.ContactLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveTable.LiveResource, schema: Microwaveprop.Radio.Contact
alias Microwaveprop.Radio
def fields do
[
id: %{label: "ID", hidden: true},
station1: %{label: "Station 1", sortable: true, searchable: true},
grid1: %{label: "Grid 1", sortable: true, searchable: true},
station2: %{label: "Station 2", sortable: true, searchable: true},
grid2: %{label: "Grid 2", sortable: true, searchable: true},
band: %{label: "Band", sortable: true, renderer: &band_cell/1},
mode: %{label: "Mode", sortable: true, searchable: true},
distance_km: %{label: "Distance (km)", sortable: true},
hrrr_status: %{label: "Enriched", renderer: &enrichment_cell/2},
flagged_invalid: %{label: "Flag", renderer: &flag_cell/1},
qso_timestamp: %{label: "QSO (UTC)", sortable: true, renderer: &format_ts/1},
inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1}
]
end
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)
@default_sort_by "inserted_at"
@default_sort_order "desc"
def filters, do: []
def actions do
[
view: fn %{record: contact} ->
assigns = %{contact: contact}
~H"""
<.link navigate={~p"/contacts/#{@contact.id}"} class="btn btn-xs btn-ghost">
View
</.link>
"""
end
]
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
{:ok, assign(socket, :page_title, "Contacts")}
end
@impl true
def handle_params(params, _uri, socket) do
page =
case Integer.parse(Map.get(params, "page", "1")) do
{n, _} -> max(n, 1)
:error -> 1
end
sort_by = validate_sort_field(Map.get(params, "sort_by", @default_sort_by))
sort_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order))
search = Map.get(params, "search", "")
result =
Radio.list_contacts(
page: page,
sort_by: String.to_existing_atom(sort_by),
sort_order: String.to_existing_atom(sort_order),
search: search
)
{:noreply,
assign(socket,
page_title: "Contacts",
page: result.page,
total_pages: result.total_pages,
total_entries: result.total_entries,
contacts: result.entries,
grouped_contacts: result.grouped_entries,
sort_by: sort_by,
sort_order: sort_order,
search: search
)}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply, push_patch(socket, to: ~p"/contacts?search=#{search}")}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field = validate_sort_field(field)
new_order =
if socket.assigns.sort_by == field && socket.assigns.sort_order == "asc",
do: "desc",
else: "asc"
params = %{sort_by: field, sort_order: new_order}
params = if socket.assigns.search == "", do: params, else: Map.put(params, :search, socket.assigns.search)
{:noreply, push_patch(socket, to: ~p"/contacts?#{params}")}
end
defp validate_sort_field(field) when field in @sortable_fields, do: field
defp validate_sort_field(_), do: @default_sort_by
defp validate_sort_order(order) when order in ~w(asc desc), do: order
defp validate_sort_order(_), do: @default_sort_order
@enrichment_fields [
{:hrrr_status, "HRRR"},
{:weather_status, "Weather"},
@ -80,7 +49,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
]
@done_statuses [:complete, :unavailable]
defp enrichment_badge(contact) do
defp enrichment_cell(_value, contact) do
details =
Enum.map(@enrichment_fields, fn {field, name} ->
{name, Map.get(contact, field)}
@ -97,10 +66,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
true -> {"Pending", "badge-ghost"}
end
tooltip =
Enum.map_join(details, ", ", fn {name, status} ->
"#{name}: #{status}"
end)
tooltip = Enum.map_join(details, ", ", fn {name, status} -> "#{name}: #{status}" end)
assigns = %{label: label, class: class, tooltip: tooltip}
@ -109,13 +75,32 @@ defmodule MicrowavepropWeb.ContactLive.Index do
"""
end
defp band_cell(nil), do: ""
defp band_cell(%Decimal{} = d), do: Decimal.to_string(d, :normal)
defp band_cell(other), do: to_string(other)
defp flag_cell(true) do
assigns = %{}
~H"""
<span class="text-error">!</span>
"""
end
defp flag_cell(_), do: ""
defp format_ts(nil), do: ""
defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
defp format_ts(other), do: to_string(other)
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-7xl">
<.header>
Contacts
<:subtitle>{@total_entries} contacts</:subtitle>
<:subtitle>Sort or search by callsign, grid, or mode.</:subtitle>
<:actions>
<.link navigate={~p"/submit"} class="btn btn-primary">
<.icon name="hero-plus" class="w-5 h-5" /> Submit Contact
@ -123,118 +108,13 @@ defmodule MicrowavepropWeb.ContactLive.Index do
</:actions>
</.header>
<form phx-submit="search" class="mb-4">
<div class="flex items-center gap-2">
<input
name="search"
value={@search}
placeholder="Search callsign or pair (e.g. W5LUA W5HN)..."
type="text"
class="input input-bordered input-sm w-64"
/>
<button type="submit" class="btn btn-sm btn-outline">Search</button>
<.link :if={@search != ""} patch={~p"/contacts"} class="btn btn-sm btn-ghost">Clear</.link>
</div>
</form>
<table class="table table-sm w-full">
<thead>
<tr>
<th class="w-8"></th>
<th>Station 1</th>
<th>Grid 1</th>
<th>Station 2</th>
<th>Grid 2</th>
<th>Band</th>
<th>Mode</th>
<th>Distance (km)</th>
<th>Enriched</th>
<th></th>
<th>Timestamp (UTC)</th>
<th>Added (UTC)</th>
</tr>
</thead>
<tbody>
<%= for {primary, reciprocals} <- @grouped_contacts do %>
<tr class="h-1">
<td colspan="12"></td>
</tr>
<tr
class={[
"hover:bg-base-200 cursor-pointer",
reciprocals != [] && "bg-primary/5"
]}
phx-click={JS.navigate(~p"/contacts/#{primary.id}")}
>
<td class="w-8 text-center">
<span
:if={reciprocals != []}
class="badge badge-primary badge-xs"
title="Has reciprocal contact"
>
{length(reciprocals) + 1}
</span>
</td>
<td class="font-mono font-semibold">{primary.station1}</td>
<td>{primary.grid1 || ""}</td>
<td class="font-mono font-semibold">{primary.station2}</td>
<td>{primary.grid2 || ""}</td>
<td>{primary.band}</td>
<td>{primary.mode}</td>
<td>{primary.distance_km}</td>
<td>{enrichment_badge(primary)}</td>
<td class="text-center">{if primary.flagged_invalid, do: "🚩"}</td>
<td>{Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
<td>{Calendar.strftime(primary.inserted_at, "%Y-%m-%d %H:%M")}</td>
</tr>
<%= for recip <- reciprocals do %>
<tr
class="hover:bg-base-200 cursor-pointer bg-primary/5 opacity-70 border-t-0"
phx-click={JS.navigate(~p"/contacts/#{recip.id}")}
>
<td class="w-8 text-center text-primary"></td>
<td class="font-mono">{recip.station1}</td>
<td>{recip.grid1 || ""}</td>
<td class="font-mono">{recip.station2}</td>
<td>{recip.grid2 || ""}</td>
<td>{recip.band}</td>
<td>{recip.mode}</td>
<td>{recip.distance_km}</td>
<td>{enrichment_badge(recip)}</td>
<td class="text-center">{if recip.flagged_invalid, do: "🚩"}</td>
<td>{Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
<td>{Calendar.strftime(recip.inserted_at, "%Y-%m-%d %H:%M")}</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<div class="flex items-center justify-between pt-4">
<.link
:if={@page > 1}
patch={
~p"/contacts?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline"
>
<.icon name="hero-chevron-left" class="w-4 h-4" /> Previous
</.link>
<span :if={@page <= 1} />
<span class="text-sm text-base-content/70">Page {@page} of {@total_pages}</span>
<.link
:if={@page < @total_pages}
patch={
~p"/contacts?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline"
>
Next <.icon name="hero-chevron-right" class="w-4 h-4" />
</.link>
<span :if={@page >= @total_pages} />
</div>
<.live_table
fields={fields()}
filters={filters()}
options={@options}
streams={@streams}
actions={actions()}
/>
</Layouts.app>
"""
end

View file

@ -72,13 +72,11 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
def handle_event("delete", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
cond do
user.id == socket.assigns.current_scope.user.id ->
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
true ->
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :resources, user)}
if user.id == socket.assigns.current_scope.user.id do
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
else
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :resources, user)}
end
end

View file

@ -74,9 +74,8 @@ defmodule MicrowavepropWeb.ContactLiveTest do
create_contact(%{station1: "ZZ9ZZ"})
create_contact(%{station1: "AA1AA"})
{:ok, _lv, html} = live(conn, ~p"/contacts?sort_by=station1&sort_order=asc")
{:ok, _lv, html} = live(conn, ~p"/contacts?sort_params[station1]=asc")
# AA1AA should appear before ZZ9ZZ
aa_pos = html |> :binary.match("AA1AA") |> elem(0)
zz_pos = html |> :binary.match("ZZ9ZZ") |> elem(0)
assert aa_pos < zz_pos
@ -87,33 +86,23 @@ defmodule MicrowavepropWeb.ContactLiveTest do
create_contact(%{station1: "K5TR", station2: "N5AC"})
{:ok, _lv, html} = live(conn, ~p"/contacts?search=W5LUA")
# W5LUA appears in the matching row; K5TR/N5AC should not render in
# the table body because the search filtered that contact out.
assert html =~ "W5LUA"
refute html =~ "K5TR"
refute html =~ "N5AC"
end
test "search by two callsigns finds contacts between that pair", %{conn: conn} do
create_contact(%{station1: "W5LUA", station2: "W5HN"})
create_contact(%{station1: "W5HN", station2: "W5LUA"})
create_contact(%{station1: "W5LUA", station2: "K5TR"})
{:ok, _lv, html} = live(conn, ~p"/contacts?search=W5LUA+W5HN")
assert html =~ "W5LUA"
assert html =~ "W5HN"
# The W5LUA-K5TR contact should not appear
refute html =~ "K5TR"
end
test "pagination works", %{conn: conn} do
test "pagination reaches later pages", %{conn: conn} do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
create_contact(%{qso_timestamp: ts, station1: "W#{i}TEST"})
end
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "Page 1 of 2"
assert html =~ "Page"
{:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
assert html2 =~ "Page 2 of 2"
assert html2 =~ "Page"
end
end