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.
This commit is contained in:
parent
fb49eb016d
commit
49ade78766
11 changed files with 204 additions and 58 deletions
|
|
@ -78,7 +78,7 @@ defmodule Microwaveprop.Accounts do
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single user.
|
Gets a single user.
|
||||||
|
|
||||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
Returns `nil` if the User does not exist.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|
@ -86,11 +86,11 @@ defmodule Microwaveprop.Accounts do
|
||||||
%User{}
|
%User{}
|
||||||
|
|
||||||
get_user!(456)
|
get_user!(456)
|
||||||
** (Ecto.NoResultsError)
|
nil
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec get_user!(Ecto.UUID.t()) :: User.t()
|
@spec get_user!(Ecto.UUID.t()) :: User.t() | nil
|
||||||
def get_user!(id), do: Repo.get!(User, id)
|
def get_user!(id), do: Repo.get(User, id)
|
||||||
|
|
||||||
## Admin user management
|
## Admin user management
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,15 @@ defmodule Microwaveprop.Beacons do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Marks a beacon as approved, making it visible in the public list."
|
@doc "Marks a beacon as approved, making it visible in the public list."
|
||||||
@spec approve_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
|
@spec approve_beacon(Beacon.t() | binary()) ::
|
||||||
|
{:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found}
|
||||||
|
def approve_beacon(id) when is_binary(id) do
|
||||||
|
case Repo.get(Beacon, id) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
beacon -> approve_beacon(beacon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def approve_beacon(%Beacon{} = beacon) do
|
def approve_beacon(%Beacon{} = beacon) do
|
||||||
beacon
|
beacon
|
||||||
|> Ecto.Changeset.change(approved: true)
|
|> Ecto.Changeset.change(approved: true)
|
||||||
|
|
@ -154,7 +162,15 @@ defmodule Microwaveprop.Beacons do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Deletes a beacon."
|
@doc "Deletes a beacon."
|
||||||
@spec delete_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
|
@spec delete_beacon(Beacon.t() | binary()) ::
|
||||||
|
{:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found}
|
||||||
|
def delete_beacon(id) when is_binary(id) do
|
||||||
|
case Repo.get(Beacon, id) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
beacon -> delete_beacon(beacon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def delete_beacon(%Beacon{} = beacon) do
|
def delete_beacon(%Beacon{} = beacon) do
|
||||||
case Repo.delete(beacon) do
|
case Repo.delete(beacon) do
|
||||||
{:ok, beacon} ->
|
{:ok, beacon} ->
|
||||||
|
|
|
||||||
|
|
@ -1075,17 +1075,25 @@ defmodule Microwaveprop.Radio do
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_contact_edit!(Ecto.UUID.t()) :: ContactEdit.t()
|
@spec get_contact_edit(Ecto.UUID.t()) :: ContactEdit.t() | nil
|
||||||
def get_contact_edit!(id) do
|
def get_contact_edit(id) do
|
||||||
ContactEdit
|
ContactEdit
|
||||||
|> preload([:user, :contact, :reviewed_by])
|
|> preload([:user, :contact, :reviewed_by])
|
||||||
|> Repo.get!(id)
|
|> Repo.get(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec approve_edit(ContactEdit.t(), User.t(), String.t() | nil) ::
|
@spec approve_edit(ContactEdit.t(), User.t(), String.t() | nil) ::
|
||||||
{:ok, ContactEdit.t()} | {:error, any()}
|
{:ok, ContactEdit.t()} | {:error, any()}
|
||||||
def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do
|
def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
|
# Check the contact still exists before proceeding — the FK has
|
||||||
|
# on_delete: :delete_all so if the contact is gone the edit row is
|
||||||
|
# also gone, making any update to it a StaleEntryError.
|
||||||
|
case Repo.get(Contact, edit.contact_id) do
|
||||||
|
nil ->
|
||||||
|
Repo.rollback(:contact_deleted)
|
||||||
|
|
||||||
|
contact ->
|
||||||
# Mark edit as approved
|
# Mark edit as approved
|
||||||
{:ok, approved} =
|
{:ok, approved} =
|
||||||
edit
|
edit
|
||||||
|
|
@ -1098,10 +1106,9 @@ defmodule Microwaveprop.Radio do
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|
||||||
# Apply changes to contact
|
# Apply changes to contact
|
||||||
contact = Repo.get!(Contact, edit.contact_id)
|
|
||||||
_ = apply_edit_to_contact(contact, edit.proposed_changes)
|
_ = apply_edit_to_contact(contact, edit.proposed_changes)
|
||||||
|
|
||||||
Repo.preload(approved, [:user, :contact, :reviewed_by])
|
Repo.preload(approved, [:user, :contact, :reviewed_by])
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,23 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|
||||||
|> assign(:reviewing, nil)
|
|> assign(:reviewing, nil)
|
||||||
|> assign(:admin_note, "")
|
|> assign(:admin_note, "")
|
||||||
|> assign(:pending_count, Radio.pending_edit_count())
|
|> assign(:pending_count, Radio.pending_edit_count())
|
||||||
|> assign(:flagged_contacts, Radio.list_flagged_contacts())}
|
|> assign(:flagged_contacts, Radio.list_flagged_contacts())
|
||||||
|
|> assign(:data_provider, {Radio, :pending_edits_query, []})}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("review", %{"id" => id}, socket) do
|
def handle_event("review", %{"id" => id}, socket) do
|
||||||
edit = Radio.get_contact_edit!(id)
|
case Radio.get_contact_edit(id) do
|
||||||
|
nil ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "That edit no longer exists — it may have been reviewed by another admin.")
|
||||||
|
|> push_patch(to: "/" <> (socket.assigns[:current_path] || "admin/contact-edits"))}
|
||||||
|
|
||||||
|
edit ->
|
||||||
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
|
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("cancel_review", _params, socket) do
|
def handle_event("cancel_review", _params, socket) do
|
||||||
{:noreply, assign(socket, reviewing: nil, admin_note: "")}
|
{:noreply, assign(socket, reviewing: nil, admin_note: "")}
|
||||||
|
|
@ -136,6 +145,9 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|
||||||
defp field_label("band"), do: "Band"
|
defp field_label("band"), do: "Band"
|
||||||
defp field_label("mode"), do: "Mode"
|
defp field_label("mode"), do: "Mode"
|
||||||
defp field_label("qso_timestamp"), do: "Timestamp"
|
defp field_label("qso_timestamp"), do: "Timestamp"
|
||||||
|
defp field_label("height1_ft"), do: "Height 1 (ft)"
|
||||||
|
defp field_label("height2_ft"), do: "Height 2 (ft)"
|
||||||
|
defp field_label("private"), do: "Private"
|
||||||
defp field_label(other), do: other
|
defp field_label(other), do: other
|
||||||
|
|
||||||
defp current_value(contact, "station1"), do: contact.station1
|
defp current_value(contact, "station1"), do: contact.station1
|
||||||
|
|
@ -144,6 +156,9 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|
||||||
defp current_value(contact, "grid2"), do: contact.grid2
|
defp current_value(contact, "grid2"), do: contact.grid2
|
||||||
defp current_value(contact, "band"), do: if(contact.band, do: Decimal.to_string(contact.band))
|
defp current_value(contact, "band"), do: if(contact.band, do: Decimal.to_string(contact.band))
|
||||||
defp current_value(contact, "mode"), do: contact.mode
|
defp current_value(contact, "mode"), do: contact.mode
|
||||||
|
defp current_value(contact, "height1_ft"), do: contact.height1_ft
|
||||||
|
defp current_value(contact, "height2_ft"), do: contact.height2_ft
|
||||||
|
defp current_value(contact, "private"), do: if(contact.private, do: "Yes", else: "No")
|
||||||
|
|
||||||
defp current_value(contact, "qso_timestamp"), do: if(contact.qso_timestamp, do: format_ts(contact.qso_timestamp))
|
defp current_value(contact, "qso_timestamp"), do: if(contact.qso_timestamp, do: format_ts(contact.qso_timestamp))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,19 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
if admin?(socket.assigns.current_scope) do
|
if admin?(socket.assigns.current_scope) do
|
||||||
beacon = Beacons.get_beacon!(id)
|
case Beacons.delete_beacon(id) do
|
||||||
{:ok, _} = Beacons.delete_beacon(beacon)
|
{:ok, deleted} ->
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> stream_delete(:pending, beacon)
|
|> stream_delete(:pending, deleted)
|
||||||
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
|
|> 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
|
else
|
||||||
{:noreply, put_flash(socket, :error, "Admins only.")}
|
{:noreply, put_flash(socket, :error, "Admins only.")}
|
||||||
end
|
end
|
||||||
|
|
@ -85,14 +91,20 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
||||||
|
|
||||||
def handle_event("approve", %{"id" => id}, socket) do
|
def handle_event("approve", %{"id" => id}, socket) do
|
||||||
if admin?(socket.assigns.current_scope) do
|
if admin?(socket.assigns.current_scope) do
|
||||||
beacon = Beacons.get_beacon!(id)
|
case Beacons.approve_beacon(id) do
|
||||||
{:ok, approved} = Beacons.approve_beacon(beacon)
|
{:ok, approved} ->
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, "Approved #{approved.callsign}.")
|
|> put_flash(:info, "Approved #{approved.callsign}.")
|
||||||
|> stream_delete(:pending, beacon)
|
|> stream_delete(:pending, approved)
|
||||||
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
|
|> 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
|
else
|
||||||
{:noreply, put_flash(socket, :error, "Admins only.")}
|
{:noreply, put_flash(socket, :error, "Admins only.")}
|
||||||
end
|
end
|
||||||
|
|
@ -106,6 +118,10 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
||||||
|> patch_beacons_json(beacon)}
|
|> patch_beacons_json(beacon)}
|
||||||
end
|
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
|
def handle_info({:updated, %Beacon{} = beacon}, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,21 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
|
||||||
@spec filters() :: list()
|
@spec filters() :: list()
|
||||||
def filters, do: []
|
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
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
socket
|
||||||
page_title: "Rover Planning"
|
|> assign(page_title: "Rover Planning")
|
||||||
)}
|
|> assign(:data_provider, {__MODULE__, :visible_query_provider, []})}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,21 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
user = Accounts.get_user!(id)
|
case Accounts.get_user!(id) do
|
||||||
|
nil ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "User not found.")
|
||||||
|
|> push_navigate(to: ~p"/users")}
|
||||||
|
|
||||||
|
user ->
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Edit user")
|
|> assign(:page_title, "Edit user")
|
||||||
|> assign(:user, user)
|
|> assign(:user, user)
|
||||||
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
|
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("validate", %{"user" => params}, socket) do
|
def handle_event("validate", %{"user" => params}, socket) do
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,11 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
user = Accounts.get_user!(id)
|
case Accounts.get_user!(id) do
|
||||||
|
nil ->
|
||||||
|
{:noreply, put_flash(socket, :error, "User not found.")}
|
||||||
|
|
||||||
|
user ->
|
||||||
if user.id == socket.assigns.current_scope.user.id do
|
if user.id == socket.assigns.current_scope.user.id do
|
||||||
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
|
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
|
||||||
else
|
else
|
||||||
|
|
@ -87,6 +90,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
|
||||||
{:noreply, stream_delete(socket, :resources, user)}
|
{:noreply, stream_delete(socket, :resources, user)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp admin_badge(true) do
|
defp admin_badge(true) do
|
||||||
assigns = %{}
|
assigns = %{}
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,8 @@ defmodule Microwaveprop.AccountsTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_user!/1" do
|
describe "get_user!/1" do
|
||||||
test "raises if id is invalid" do
|
test "returns nil if id is invalid" do
|
||||||
assert_raise Ecto.NoResultsError, fn ->
|
assert Accounts.get_user!("11111111-1111-1111-1111-111111111111") == nil
|
||||||
Accounts.get_user!("11111111-1111-1111-1111-111111111111")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns the user with the given id" do
|
test "returns the user with the given id" do
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,20 @@ defmodule Microwaveprop.Radio.ContactEditTest do
|
||||||
assert updated.grid1 == "EM15AB"
|
assert updated.grid1 == "EM15AB"
|
||||||
assert updated.pos1 != contact.pos1
|
assert updated.pos1 != contact.pos1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns {:error, :contact_deleted} when the contact was removed before approval", %{
|
||||||
|
contact: contact,
|
||||||
|
user: user,
|
||||||
|
admin: admin
|
||||||
|
} do
|
||||||
|
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
|
||||||
|
|
||||||
|
# Delete the contact before approving — the FK cascade (on_delete:
|
||||||
|
# :delete_all) removes the edit row too.
|
||||||
|
Repo.delete!(contact)
|
||||||
|
|
||||||
|
assert {:error, :contact_deleted} = Radio.approve_edit(edit, admin, nil)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Radio.reject_edit/3" do
|
describe "Radio.reject_edit/3" do
|
||||||
|
|
|
||||||
|
|
@ -207,4 +207,64 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
|
||||||
assert Repo.get!(ContactEdit, edit.id).status == :pending
|
assert Repo.get!(ContactEdit, edit.id).status == :pending
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "table rendering" do
|
||||||
|
defp seed_two_edits do
|
||||||
|
admin = admin_fixture()
|
||||||
|
submitter = user_fixture()
|
||||||
|
contact = create_contact(%{station1: "N5XXX"})
|
||||||
|
|
||||||
|
{:ok, edit1} =
|
||||||
|
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
|
||||||
|
"station1" => "N5YYY"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, edit2} =
|
||||||
|
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
|
||||||
|
"mode" => "SSB"
|
||||||
|
})
|
||||||
|
|
||||||
|
{admin, contact, submitter, edit1, edit2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows pending edits with station callsigns and submitter callsign in table", %{conn: conn} do
|
||||||
|
{admin, _contact, submitter, _edit1, _edit2} = seed_two_edits()
|
||||||
|
|
||||||
|
{:ok, _lv, html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(admin)
|
||||||
|
|> live(~p"/admin/contact-edits")
|
||||||
|
|
||||||
|
# Table cells should show station callsigns (contact_cell) and
|
||||||
|
# submitter callsign (user_cell).
|
||||||
|
assert html =~ "N5XXX"
|
||||||
|
assert html =~ submitter.callsign
|
||||||
|
end
|
||||||
|
|
||||||
|
test "approved edit is removed from the table", %{conn: conn} do
|
||||||
|
{admin, _contact, _submitter, edit1, _edit2} = seed_two_edits()
|
||||||
|
|
||||||
|
{:ok, lv, html} =
|
||||||
|
conn
|
||||||
|
|> log_in_user(admin)
|
||||||
|
|> live(~p"/admin/contact-edits")
|
||||||
|
|
||||||
|
# Both edits are pending and visible.
|
||||||
|
assert html =~ "2 pending"
|
||||||
|
assert has_element?(lv, ~s|button[phx-value-id="#{edit1.id}"]|)
|
||||||
|
|
||||||
|
# Approve edit1.
|
||||||
|
lv
|
||||||
|
|> element(~s|button[phx-click="review"][phx-value-id="#{edit1.id}"]|)
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
html = lv |> element(~s|button[phx-click="approve"]|) |> render_click()
|
||||||
|
|
||||||
|
# Counter drops to 1.
|
||||||
|
assert html =~ "1 pending"
|
||||||
|
|
||||||
|
# The approved edit's review button is gone from the table.
|
||||||
|
refute html =~ "phx-value-id=\"#{edit1.id}\""
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue