prop/lib/microwaveprop_web/live/beacon_live/index.ex
Graham McInitre 49ade78766 fix: wire pending_edits_query as data_provider for contact edit review table
The LiveTable on /admin/contact-edits used the bare ContactEdit schema
as its data source, which caused three symptoms:
- '0 pending' counter but stale approved/rejected edits still visible
- Blank contact/submitted-by cells (select_columns stripped preloaded
  associations, cell renderers received flat maps with no :contact/:user)
- Approve/reject didn't remove the row from the table

Fix: assign {Radio, :pending_edits_query, []} as the data_provider in
mount so handle_params threads it to stream_resources. The query variant
of list_resources preserves preloaded associations and includes the
WHERE status = :pending filter.

Added two tests that verify the table rendering and edit removal.
2026-07-22 08:54:46 -05:00

278 lines
8.1 KiB
Elixir

defmodule MicrowavepropWeb.BeaconLive.Index do
@moduledoc "`/beacons` list + admin controls for the beacon catalog."
use MicrowavepropWeb, :live_view
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Beacons.Beacon
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@spec table_options() :: map()
def table_options do
%{exports: %{formats: [:csv]}}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
frequency_mhz: %{
label: "Frequency (MHz)",
sortable: true,
searchable: false,
renderer: &freq_cell/1
},
callsign: %{label: "Call", sortable: true, searchable: true},
grid: %{label: "Grid", sortable: true, searchable: true},
power_mw: %{label: "EIRP (mW)", sortable: true, renderer: &mw_cell/1},
height_ft: %{label: "Height AGL (ft)", sortable: true},
keying: %{label: "Keying", sortable: true, renderer: &keying_cell/1},
on_the_air: %{label: "On air", sortable: true, renderer: &on_air_cell/1}
]
end
@spec filters() :: list()
def filters, do: []
@spec actions() :: keyword()
def actions do
[
show: fn %{record: beacon} ->
assigns = %{beacon: beacon}
~H"""
<.link navigate={~p"/beacons/#{@beacon.id}"} class="btn btn-xs btn-ghost">Details</.link>
"""
end
]
end
@impl true
def mount(_params, _session, socket) do
_ = if connected?(socket), do: Beacons.subscribe_beacons()
beacons = Beacons.list_beacons()
pending =
if admin?(socket.assigns.current_scope) do
Beacons.list_pending_beacons()
else
[]
end
{:ok,
socket
|> assign(:page_title, "Beacons")
|> assign(:pending, pending)
|> assign(:beacons_json, encode_beacons(beacons))
|> assign(:data_provider, {Beacons, :approved_beacons_query, []})
|> stream(:pending, pending)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
case Beacons.delete_beacon(id) do
{:ok, deleted} ->
{:noreply,
socket
|> stream_delete(:pending, deleted)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:error, :not_found} ->
{:noreply, push_patch(socket, to: path_with_prefix(socket.assigns.current_path))}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
def handle_event("approve", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
case Beacons.approve_beacon(id) do
{:ok, approved} ->
{:noreply,
socket
|> put_flash(:info, "Approved #{approved.callsign}.")
|> stream_delete(:pending, approved)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:error, :not_found} ->
{:noreply, push_patch(socket, to: path_with_prefix(socket.assigns.current_path))}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
@impl true
def handle_info({:created, %Beacon{} = beacon}, socket) do
{:noreply,
socket
|> stream_insert(:pending, beacon)
|> patch_beacons_json(beacon)}
end
def handle_info({:updated, %Beacon{approved: true} = beacon}, socket) do
{:noreply, patch_beacons_json(socket, beacon)}
end
def handle_info({:updated, %Beacon{} = beacon}, socket) do
{:noreply,
socket
|> stream_insert(:pending, beacon)
|> patch_beacons_json(beacon)}
end
def handle_info({:deleted, %Beacon{} = beacon}, socket) do
{:noreply,
socket
|> stream_delete(:pending, beacon)
|> remove_from_beacons_json(beacon)}
end
# Replace or add a beacon in the in-memory JSON list without re-querying the DB.
defp patch_beacons_json(%{assigns: %{beacons_json: json}} = socket, beacon) do
entry = encode_single_beacon(beacon)
updated = Enum.reject(json, &(Map.get(&1, "id") == Map.get(entry, "id"))) ++ [entry]
assign(socket, :beacons_json, updated)
end
defp remove_from_beacons_json(%{assigns: %{beacons_json: json}} = socket, beacon) do
id_str = beacon.id |> Ecto.UUID.cast!() |> to_string()
updated = Enum.reject(json, &(Map.get(&1, "id") == id_str))
assign(socket, :beacons_json, updated)
end
defp encode_single_beacon(beacon) do
%{
"id" => beacon.id |> Ecto.UUID.cast!() |> to_string(),
"lat" => beacon.lat,
"lon" => beacon.lon,
"callsign" => beacon.callsign,
"frequency_mhz" => beacon.frequency_mhz,
"grid" => beacon.grid,
"approved" => beacon.approved
}
end
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
defp path_with_prefix(path) when is_binary(path), do: "/" <> String.trim_leading(path, "/")
defp path_with_prefix(_), do: "/beacons"
defp encode_beacons(beacons) do
beacons
|> Enum.filter(&(is_number(&1.lat) and is_number(&1.lon)))
|> Enum.map(fn b ->
%{
id: b.id,
callsign: b.callsign,
lat: b.lat,
lon: b.lon,
frequency_mhz: b.frequency_mhz,
grid: b.grid,
keying: Beacon.keying_label(b.keying),
on_the_air: b.on_the_air
}
end)
|> Jason.encode!()
end
defp freq_cell(freq), do: Beacon.format_freq(freq)
defp mw_cell(power), do: Beacon.format_mw(power)
defp keying_cell(keying), do: Beacon.keying_label(keying)
defp on_air_cell(on_the_air) do
assigns = %{on_the_air: on_the_air}
~H"""
<span class={[
"badge badge-sm",
(@on_the_air && "badge-success") || "badge-ghost"
]}>
{if @on_the_air, do: "Yes", else: "No"}
</span>
"""
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Beacons
<:subtitle>Microwave beacons tracked by NTMS.</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/beacons/new"}>
<.icon name="hero-plus" /> Submit Beacon
</.button>
</:actions>
</.header>
<div
id="beacons-map"
phx-hook="BeaconsListMap"
phx-update="ignore"
data-beacons={@beacons_json}
class="h-80 w-full rounded-lg border border-base-300 mb-6 z-0"
>
</div>
<.live_table
fields={fields()}
filters={filters()}
options={@options}
streams={@streams}
actions={actions()}
/>
<div :if={admin?(@current_scope) and @pending != []} class="mt-10">
<.header>
Pending approval
<:subtitle>Submitted beacons waiting for admin review.</:subtitle>
</.header>
<.table
id="pending-beacons"
rows={@streams.pending}
row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end}
>
<:col :let={{_id, beacon}} label="Frequency (MHz)">
{Beacon.format_freq(beacon.frequency_mhz)}
</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Submitted">
{Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")}
</:col>
<:action :let={{_id, beacon}}>
<.link
phx-click={JS.push("approve", value: %{id: beacon.id})}
data-confirm="Approve this beacon?"
>
Approve
</.link>
</:action>
<:action :let={{id, beacon}}>
<.link
phx-click={JS.push("delete", value: %{id: beacon.id}) |> hide("##{id}")}
data-confirm="Delete this beacon?"
>
Delete
</.link>
</:action>
</.table>
</div>
</Layouts.app>
"""
end
end