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:
parent
606340e1c7
commit
f733ddb6ac
3 changed files with 75 additions and 208 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue