feat(rover-planning): add/remove rover sites inline on the show page
A new "Rover sites" section between Stationary stations and Path profiles lists the candidate rover locations the mission scores against, with: - An add form that takes the same flexible input as station inputs (callsign, Maidenhead grid, or `lat,lon`); on submit it creates a global :good rover-location and re-runs the path matrix. - A per-row trash button visible to the location's owner / admins that deletes the location and re-runs the matrix. `RoverPlanning.candidate_rover_locations/1` is now public so the show page can list exactly what the worker enqueues against. Add/remove both call `replace_mission_paths/1` so the matrix stays consistent with the rover-site set. Anonymous visitors see a sign-in prompt instead of the form.
This commit is contained in:
parent
5c3a791665
commit
8fd9759e4e
3 changed files with 192 additions and 5 deletions
|
|
@ -137,12 +137,18 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp candidate_rover_locations(%Mission{only_known_good: true}) do
|
@doc """
|
||||||
Repo.all(from(l in Location, where: l.status == :good))
|
Rover-locations the matrix actually scores against, given the
|
||||||
|
mission's `only_known_good` flag. Public so the show LiveView can
|
||||||
|
render the same list the worker enqueues against.
|
||||||
|
"""
|
||||||
|
@spec candidate_rover_locations(Mission.t()) :: [Location.t()]
|
||||||
|
def candidate_rover_locations(%Mission{only_known_good: true}) do
|
||||||
|
Repo.all(from(l in Location, where: l.status == :good, order_by: [desc: l.inserted_at]))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp candidate_rover_locations(%Mission{only_known_good: false}) do
|
def candidate_rover_locations(%Mission{only_known_good: false}) do
|
||||||
Repo.all(Location)
|
Repo.all(from(l in Location, order_by: [desc: l.inserted_at]))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp insert_pending_paths_and_jobs(mission, pairs) do
|
defp insert_pending_paths_and_jobs(mission, pairs) do
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
alias Microwaveprop.Rover
|
||||||
|
alias Microwaveprop.Rover.Location
|
||||||
alias Microwaveprop.RoverPlanning
|
alias Microwaveprop.RoverPlanning
|
||||||
alias Microwaveprop.RoverPlanning.Mission
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
alias MicrowavepropWeb.LocationResolver
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -30,7 +33,9 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
assign(socket,
|
assign(socket,
|
||||||
page_title: "Mission · #{mission.name}",
|
page_title: "Mission · #{mission.name}",
|
||||||
mission: mission,
|
mission: mission,
|
||||||
paths: RoverPlanning.list_paths(mission)
|
paths: RoverPlanning.list_paths(mission),
|
||||||
|
rover_sites: RoverPlanning.candidate_rover_locations(mission),
|
||||||
|
rover_site_input: ""
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -41,6 +46,57 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_event("rover_site_input_change", %{"input" => input}, socket) do
|
||||||
|
{:noreply, assign(socket, rover_site_input: input)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("add_rover_site", %{"input" => input}, socket) do
|
||||||
|
with %User{} = user <- current_user(socket) || :anonymous,
|
||||||
|
{:ok, %{lat: lat, lon: lon}} <- LocationResolver.resolve(input),
|
||||||
|
attrs = %{lat: lat, lon: lon, status: :good, notes: String.trim(input)},
|
||||||
|
{:ok, _loc} <- Rover.create_location(user, attrs) do
|
||||||
|
:ok = RoverPlanning.replace_mission_paths(socket.assigns.mission)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
rover_sites: RoverPlanning.candidate_rover_locations(socket.assigns.mission),
|
||||||
|
paths: RoverPlanning.list_paths(socket.assigns.mission),
|
||||||
|
rover_site_input: ""
|
||||||
|
)
|
||||||
|
|> put_flash(:info, "Rover site added — recomputing paths.")}
|
||||||
|
else
|
||||||
|
:anonymous -> {:noreply, put_flash(socket, :error, "Sign in to add rover sites.")}
|
||||||
|
:empty -> {:noreply, put_flash(socket, :error, "Enter a callsign, grid, or lat,lon.")}
|
||||||
|
{:error, msg} when is_binary(msg) -> {:noreply, put_flash(socket, :error, msg)}
|
||||||
|
{:error, %Ecto.Changeset{} = cs} -> {:noreply, put_flash(socket, :error, error_summary(cs))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("delete_rover_site", %{"id" => id}, socket) do
|
||||||
|
case current_user(socket) do
|
||||||
|
%User{} = user ->
|
||||||
|
case Rover.delete_location(user, id) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok = RoverPlanning.replace_mission_paths(socket.assigns.mission)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
rover_sites: RoverPlanning.candidate_rover_locations(socket.assigns.mission),
|
||||||
|
paths: RoverPlanning.list_paths(socket.assigns.mission)
|
||||||
|
)
|
||||||
|
|> put_flash(:info, "Rover site removed — recomputing paths.")}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "You can only delete rover sites you created.")}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Sign in to remove rover sites.")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("delete", _params, socket) do
|
def handle_event("delete", _params, socket) do
|
||||||
case current_user(socket) do
|
case current_user(socket) do
|
||||||
%User{} = user ->
|
%User{} = user ->
|
||||||
|
|
@ -73,6 +129,21 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
|
|
||||||
defp can_modify?(_, _), do: false
|
defp can_modify?(_, _), do: false
|
||||||
|
|
||||||
|
defp can_delete_site?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
|
||||||
|
|
||||||
|
defp can_delete_site?(%{current_scope: %{user: %User{id: id}}}, %Location{user_id: id}) when not is_nil(id), do: true
|
||||||
|
|
||||||
|
defp can_delete_site?(_, _), do: false
|
||||||
|
|
||||||
|
defp error_summary(%Ecto.Changeset{} = cs) do
|
||||||
|
cs.errors
|
||||||
|
|> Enum.map_join("; ", fn {field, {msg, _}} -> "#{field} #{msg}" end)
|
||||||
|
|> case do
|
||||||
|
"" -> "Could not save rover site."
|
||||||
|
summary -> summary
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
|
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
|
||||||
ghz = mhz / 1000
|
ghz = mhz / 1000
|
||||||
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
|
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
|
||||||
|
|
@ -271,6 +342,69 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-2 gap-2 flex-wrap">
|
||||||
|
<h3 class="font-semibold">Rover sites</h3>
|
||||||
|
<span class="text-xs text-base-content/60">
|
||||||
|
{length(@rover_sites)} site(s) — paths recompute on add/remove
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul :if={@rover_sites != []} class="text-sm space-y-1 mb-3">
|
||||||
|
<li :for={site <- @rover_sites} class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-mono">{Maidenhead.from_latlon(site.lat, site.lon, 10)}</span>
|
||||||
|
<span class="text-base-content/60 font-mono text-xs">
|
||||||
|
({Float.round(site.lat, 4)}, {Float.round(site.lon, 4)})
|
||||||
|
</span>
|
||||||
|
<span :if={site.notes && site.notes != ""} class="text-base-content/60 text-xs">
|
||||||
|
· {site.notes}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
:if={can_delete_site?(assigns, site)}
|
||||||
|
type="button"
|
||||||
|
phx-click="delete_rover_site"
|
||||||
|
phx-value-id={site.id}
|
||||||
|
data-confirm="Remove this rover site? This affects every mission that uses it."
|
||||||
|
class="btn btn-ghost btn-xs text-error ml-auto"
|
||||||
|
title="Remove rover site"
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p :if={@rover_sites == []} class="text-sm text-base-content/60 mb-3">
|
||||||
|
No rover sites match this mission's scope yet — add one below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%!--
|
||||||
|
The add form takes the same flexible input the station inputs use:
|
||||||
|
callsign, Maidenhead grid, or `lat, lon`. Sign-in required.
|
||||||
|
--%>
|
||||||
|
<form
|
||||||
|
:if={@current_scope && @current_scope.user}
|
||||||
|
phx-submit="add_rover_site"
|
||||||
|
phx-change="rover_site_input_change"
|
||||||
|
class="flex flex-wrap gap-2 items-stretch"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="input"
|
||||||
|
value={@rover_site_input}
|
||||||
|
placeholder="Callsign, EM12kp, or 32.91, -97.06"
|
||||||
|
class="input input-sm flex-1 min-w-[14rem]"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" disabled={@rover_site_input == ""}>
|
||||||
|
<.icon name="hero-plus" class="w-4 h-4" /> Add rover site
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p :if={!(@current_scope && @current_scope.user)} class="text-xs text-base-content/60">
|
||||||
|
<.link navigate={~p"/users/log-in"} class="link">Sign in</.link>
|
||||||
|
to add or remove rover sites.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 border border-base-300 p-4">
|
<div class="card bg-base-100 border border-base-300 p-4">
|
||||||
<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
||||||
<h3 class="font-semibold">Path profiles</h3>
|
<h3 class="font-semibold">Path profiles</h3>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Rover
|
alias Microwaveprop.Rover
|
||||||
|
alias Microwaveprop.Rover.Location
|
||||||
alias Microwaveprop.RoverPlanning
|
alias Microwaveprop.RoverPlanning
|
||||||
alias Microwaveprop.RoverPlanning.Path
|
alias Microwaveprop.RoverPlanning.Path
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
|
|
@ -207,6 +208,52 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
assert html =~ "Clear"
|
assert html =~ "Clear"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "owner can add a rover site from the show page", %{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
mission = create_mission(user, "Add-site mission")
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||||||
|
|
||||||
|
html =
|
||||||
|
lv
|
||||||
|
|> form("form[phx-submit='add_rover_site']", %{"input" => "33.5, -97.5"})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
# New site appears in the rover-sites list, and a flash confirms.
|
||||||
|
assert html =~ "Rover site added"
|
||||||
|
assert html =~ Maidenhead.from_latlon(33.5, -97.5, 10)
|
||||||
|
assert Repo.aggregate(Location, :count) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "owner can remove a rover site from the show page", %{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
mission = create_mission(user, "Remove-site mission")
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
|
||||||
|
[site] = Repo.all(Location)
|
||||||
|
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||||||
|
|
||||||
|
html =
|
||||||
|
lv
|
||||||
|
|> element("button[phx-click='delete_rover_site'][phx-value-id='#{site.id}']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert html =~ "Rover site removed"
|
||||||
|
assert Repo.aggregate(Location, :count) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "anonymous visitor sees a sign-in prompt instead of the add form", %{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
mission = create_mission(user, "Anon-view mission")
|
||||||
|
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||||||
|
|
||||||
|
refute has_element?(lv, "form[phx-submit='add_rover_site']")
|
||||||
|
assert has_element?(lv, "a", "Sign in")
|
||||||
|
end
|
||||||
|
|
||||||
test "redirects when mission missing", %{conn: conn} do
|
test "redirects when mission missing", %{conn: conn} do
|
||||||
missing = Ecto.UUID.generate()
|
missing = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue