prop/lib/microwaveprop_web/live/rover_planning_live.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

224 lines
6.4 KiB
Elixir

defmodule MicrowavepropWeb.RoverPlanningLive do
@moduledoc """
`/rover-planning` paginated table of planned rover missions.
Globally visible, owner-mutable; admins can edit/delete any mission.
"""
use MicrowavepropWeb, :live_view
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
@spec table_options() :: map()
def table_options do
%{sorting: %{default_sort: [inserted_at: :desc]}}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
user_id: %{label: "Owner", hidden: true},
name: %{label: "Mission", sortable: true, searchable: true, renderer: &name_cell/2},
band_mhz: %{label: "Bands", sortable: true, renderer: &bands_cell/2},
only_known_good: %{label: "Scope", sortable: true, renderer: &scope_cell/1},
inserted_at: %{label: "Created", sortable: true, renderer: &format_ts/1}
]
end
@spec filters() :: list()
def filters, do: []
@doc false
# LiveTable data_provider: returns a base query so sort/search/pagination
# are applied on top and results come back as full %Mission{} structs
# (the `select_columns` path strips non-field columns like `bands_mhz`).
@spec visible_query_provider() :: Ecto.Query.t()
def visible_query_provider do
from(m in Mission, as: :resource)
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(page_title: "Rover Planning")
|> assign(:data_provider, {__MODULE__, :visible_query_provider, []})}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case current_user(socket) do
%User{} = user ->
case RoverPlanning.delete_mission(user, id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Mission deleted.")
|> reload()}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
end
_ ->
{:noreply, put_flash(socket, :error, "Sign in required.")}
end
end
defp reload(socket) do
options = socket.assigns.options
data_provider = socket.assigns[:data_provider]
table_options = LiveTable.TableConfig.get_table_options(table_options())
{resources, updated_options} = fetch_resources(options, data_provider)
socket
|> assign_to_socket(resources, table_options)
|> assign(:options, updated_options)
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
defp scope_user(assigns) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(scope, %{user_id: user_id}) do
case scope do
%{user: %User{is_admin: true}} -> true
%{user: %User{id: ^user_id}} when not is_nil(user_id) -> true
_ -> false
end
end
defp can_modify?(_, _), do: false
defp actions_for(scope) do
[
buttons: fn %{record: record} ->
row_actions(record, scope)
end
]
end
defp row_actions(record, scope) do
assigns = %{record: record, can_modify?: can_modify?(scope, record)}
~H"""
<div class="flex gap-2">
<.link
navigate={~p"/rover-planning/#{@record.id}"}
class="btn btn-xs btn-ghost"
>
View
</.link>
<.link
:if={@can_modify?}
navigate={~p"/rover-planning/#{@record.id}/edit"}
class="btn btn-xs btn-ghost"
>
Edit
</.link>
<button
:if={@can_modify?}
type="button"
phx-click="delete"
phx-value-id={@record.id}
data-confirm="Delete this mission?"
class="btn btn-xs btn-ghost text-error"
>
Delete
</button>
</div>
"""
end
defp name_cell(value, %{id: id}) do
assigns = %{id: id, name: value || "(unnamed)"}
~H"""
<.link navigate={~p"/rover-planning/#{@id}"} class="font-semibold hover:underline">
{@name}
</.link>
"""
end
# Renders the full bands list (multi-band missions can have several;
# the bound column is `band_mhz` so the table can still sort by the
# primary band, but the cell shows every band the matrix scores).
defp bands_cell(_value, record) do
case Mission.bands(struct(Mission, Map.take(record, [:band_mhz, :bands_mhz]))) do
[] -> ""
[single] -> band_label(single)
list -> Enum.map_join(list, " · ", &band_label/1)
end
end
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
ghz = mhz / 1000
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: Float.to_string(Float.round(ghz, 1))
"#{label} GHz"
end
defp band_label(mhz) when is_integer(mhz), do: "#{mhz} MHz"
defp band_label(_), do: ""
defp scope_cell(true) do
assigns = %{}
~H|<span class="badge badge-success badge-sm">Known good only</span>|
end
defp scope_cell(false) do
assigns = %{}
~H|<span class="badge badge-info badge-sm">All locations</span>|
end
defp scope_cell(_), do: ""
defp format_ts(nil), do: ""
defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
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-6xl">
<.header>
Rover Planning
<:subtitle>
Plan rover missions: enter your stationary stations, pick a band, and
we compute terrain path profiles from every known-good rover location.
</:subtitle>
<:actions>
<.link
:if={current_user(assigns)}
navigate={~p"/rover-planning/new"}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-5 h-5" /> New mission
</.link>
</:actions>
</.header>
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to plan a mission.
</p>
<.live_table
fields={fields()}
filters={filters()}
options={@options}
streams={@streams}
actions={actions_for(@current_scope)}
/>
</Layouts.app>
"""
end
end