diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index 6590b19b..a9f751ef 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -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 + + """ + 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""" + ! + """ + 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""" <.header> Contacts - <:subtitle>{@total_entries} contacts + <:subtitle>Sort or search by callsign, grid, or mode. <: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 -
-
- - - <.link :if={@search != ""} patch={~p"/contacts"} class="btn btn-sm btn-ghost">Clear -
-
- - - - - - - - - - - - - - - - - - - - <%= for {primary, reciprocals} <- @grouped_contacts do %> - - - - - - - - - - - - - - - - - - <%= for recip <- reciprocals do %> - - - - - - - - - - - - - - - <% end %> - <% end %> - -
Station 1Grid 1Station 2Grid 2BandModeDistance (km)EnrichedTimestamp (UTC)Added (UTC)
- - {length(reciprocals) + 1} - - {primary.station1}{primary.grid1 || "—"}{primary.station2}{primary.grid2 || "—"}{primary.band}{primary.mode}{primary.distance_km}{enrichment_badge(primary)}{if primary.flagged_invalid, do: "🚩"}{Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")}{Calendar.strftime(primary.inserted_at, "%Y-%m-%d %H:%M")}
{recip.station1}{recip.grid1 || "—"}{recip.station2}{recip.grid2 || "—"}{recip.band}{recip.mode}{recip.distance_km}{enrichment_badge(recip)}{if recip.flagged_invalid, do: "🚩"}{Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")}{Calendar.strftime(recip.inserted_at, "%Y-%m-%d %H:%M")}
- -
- <.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 - - - - Page {@page} of {@total_pages} - - <.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" /> - - = @total_pages} /> -
+ <.live_table + fields={fields()} + filters={filters()} + options={@options} + streams={@streams} + actions={actions()} + />
""" end diff --git a/lib/microwaveprop_web/live/user_management_live/index.ex b/lib/microwaveprop_web/live/user_management_live/index.ex index 6906095f..88270471 100644 --- a/lib/microwaveprop_web/live/user_management_live/index.ex +++ b/lib/microwaveprop_web/live/user_management_live/index.ex @@ -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 diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index 1d452886..815b5295 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -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