feat(rover): /rover-locations/:id detail page with map + marker

Each row now has a View link (and the location cell links too) to a
new show LiveView that renders the full record alongside a Leaflet
map centered on the marker. Owners get a Delete button there as well.
This commit is contained in:
Graham McIntire 2026-05-02 17:02:00 -05:00
parent 4a82571cda
commit d44351366a
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 263 additions and 5 deletions

View file

@ -277,8 +277,15 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
assigns = %{record: record, owner?: owner?(scope, record)} assigns = %{record: record, owner?: owner?(scope, record)}
~H""" ~H"""
<div :if={@owner?} class="flex gap-2"> <div class="flex gap-2">
<.link
navigate={~p"/rover-locations/#{@record.id}"}
class="btn btn-xs btn-ghost"
>
View
</.link>
<button <button
:if={@owner?}
type="button" type="button"
phx-click="edit" phx-click="edit"
phx-value-id={@record.id} phx-value-id={@record.id}
@ -287,6 +294,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
Edit Edit
</button> </button>
<button <button
:if={@owner?}
type="button" type="button"
phx-click="delete" phx-click="delete"
phx-value-id={@record.id} phx-value-id={@record.id}
@ -311,17 +319,17 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
defp status_cell(_), do: "" defp status_cell(_), do: ""
defp location_cell(_value, %{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do defp location_cell(_value, %{id: id, lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do
grid = Maidenhead.from_latlon(lat, lon, 10) grid = Maidenhead.from_latlon(lat, lon, 10)
assigns = %{lat: lat, lon: lon, grid: grid} assigns = %{id: id, lat: lat, lon: lon, grid: grid}
~H""" ~H"""
<div class="flex flex-col"> <.link navigate={~p"/rover-locations/#{@id}"} class="flex flex-col hover:underline">
<span class="font-mono text-sm font-semibold">{@grid}</span> <span class="font-mono text-sm font-semibold">{@grid}</span>
<span class="font-mono text-xs text-base-content/60"> <span class="font-mono text-xs text-base-content/60">
{Float.round(@lat, 5)}, {Float.round(@lon, 5)} {Float.round(@lat, 5)}, {Float.round(@lon, 5)}
</span> </span>
</div> </.link>
""" """
end end

View file

@ -0,0 +1,160 @@
defmodule MicrowavepropWeb.RoverLocationsLive.Show do
@moduledoc "Detail page for a single rover location, with a map + marker."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.Rover.Location
@impl true
def mount(%{"id" => id}, _session, socket) do
case load_location(id) do
%Location{} = loc ->
{:ok,
assign(socket,
page_title: "Rover Location",
location: loc,
grid: Maidenhead.from_latlon(loc.lat, loc.lon, 10)
)}
nil ->
{:ok,
socket
|> put_flash(:error, "Location not found.")
|> push_navigate(to: ~p"/rover-locations")}
end
end
@impl true
def handle_event("delete", _params, socket) do
case current_user(socket.assigns[:current_scope]) do
%User{} = user ->
case Rover.delete_location(user, socket.assigns.location.id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Location removed.")
|> push_navigate(to: ~p"/rover-locations")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "You can only delete your own locations.")}
end
_ ->
{:noreply, put_flash(socket, :error, "Sign in required.")}
end
end
defp load_location(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} -> Location |> Repo.get(uuid) |> Repo.preload(:user)
:error -> nil
end
end
defp current_user(scope) do
case scope do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp owner?(scope, %Location{user_id: user_id}) do
case current_user(scope) do
%User{id: ^user_id} -> true
_ -> false
end
end
defp status_label(:ideal), do: "Ideal"
defp status_label(:off_limits), do: "Off Limits"
defp status_label(_), do: ""
defp status_class(:ideal), do: "badge badge-success"
defp status_class(:off_limits), do: "badge badge-error"
defp status_class(_), do: "badge"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
<.header>
Rover Location
<:subtitle>
<span class={status_class(@location.status)}>{status_label(@location.status)}</span>
{@grid}
</:subtitle>
<:actions>
<.link navigate={~p"/rover-locations"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back
</.link>
<button
:if={owner?(@current_scope, @location)}
type="button"
phx-click="delete"
data-confirm="Delete this location?"
class="btn btn-ghost btn-sm text-error"
>
<.icon name="hero-trash" class="w-4 h-4" /> Delete
</button>
</:actions>
</.header>
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
<dl class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div>
<dt class="text-base-content/60">Grid</dt>
<dd class="font-mono">{@grid}</dd>
</div>
<div>
<dt class="text-base-content/60">Coordinates</dt>
<dd class="font-mono">
{Float.round(@location.lat, 6)}, {Float.round(@location.lon, 6)}
</dd>
</div>
<div>
<dt class="text-base-content/60">Status</dt>
<dd>
<span class={status_class(@location.status)}>
{status_label(@location.status)}
</span>
</dd>
</div>
<div>
<dt class="text-base-content/60">Added</dt>
<dd>{Calendar.strftime(@location.inserted_at, "%Y-%m-%d %H:%M UTC")}</dd>
</div>
<div :if={@location.user}>
<dt class="text-base-content/60">Submitted by</dt>
<dd>
<.link
navigate={~p"/u/#{@location.user.callsign}"}
class="link link-primary font-mono"
>
{@location.user.callsign}
</.link>
</dd>
</div>
</dl>
<div :if={@location.notes && @location.notes != ""} class="mt-4">
<h3 class="text-sm text-base-content/60 mb-1">Notes</h3>
<p class="whitespace-pre-line text-sm">{@location.notes}</p>
</div>
</div>
<div
id={"location-map-#{@location.id}"}
phx-hook="LocationMap"
phx-update="ignore"
data-lat={@location.lat}
data-lon={@location.lon}
class="h-[70vh] rounded border border-base-300 z-0"
>
</div>
</Layouts.app>
"""
end
end

View file

@ -197,6 +197,7 @@ defmodule MicrowavepropWeb.Router do
live "/skewt", SkewtLive live "/skewt", SkewtLive
live "/rover", RoverLive live "/rover", RoverLive
live "/rover-locations", RoverLocationsLive live "/rover-locations", RoverLocationsLive
live "/rover-locations/:id", RoverLocationsLive.Show
live "/algo", AlgoLive live "/algo", AlgoLive
live "/about", AboutLive live "/about", AboutLive
live "/privacy", PrivacyLive live "/privacy", PrivacyLive

View file

@ -0,0 +1,68 @@
defmodule MicrowavepropWeb.RoverLocationsLive.ShowTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Rover
describe "show" do
test "renders the location and a map container", %{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} =
Rover.create_location(user, %{
lat: 32.5,
lon: -97.5,
status: :ideal,
notes: "great spot"
})
{:ok, lv, html} = live(conn, ~p"/rover-locations/#{loc.id}")
assert html =~ "Rover Location"
assert html =~ "great spot"
assert html =~ "EM12gm"
assert has_element?(lv, "#location-map-#{loc.id}[phx-hook=LocationMap]")
end
test "redirects when the location does not exist", %{conn: conn} do
missing_id = Ecto.UUID.generate()
assert {:error, {:live_redirect, %{to: "/rover-locations"}}} =
live(conn, ~p"/rover-locations/#{missing_id}")
end
test "redirects when the id is not a valid UUID", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/rover-locations"}}} =
live(conn, ~p"/rover-locations/not-a-uuid")
end
test "owner sees a Delete button; visitors do not", %{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :ideal})
# Anonymous: no Delete
{:ok, _lv, html} = live(conn, ~p"/rover-locations/#{loc.id}")
refute html =~ "Delete"
# Owner: Delete present
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/rover-locations/#{loc.id}")
assert html =~ "Delete"
end
test "owner can delete from the show page and is redirected", %{conn: conn} do
user = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :ideal})
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-locations/#{loc.id}")
assert {:ok, _index_lv, _html} =
lv
|> element("button", "Delete")
|> render_click()
|> follow_redirect(conn, ~p"/rover-locations")
end
end
end

View file

@ -121,6 +121,27 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do
assert html =~ ~s(value="EM12gm") assert html =~ ~s(value="EM12gm")
end end
test "View link on each row navigates to the show page", %{conn: conn, user: user} do
{:ok, loc} =
Rover.create_location(user, %{
lat: 32.5,
lon: -97.5,
status: :ideal,
notes: "rooftop spot"
})
{:ok, lv, _html} = live(conn, ~p"/rover-locations")
assert {:ok, _show_lv, html} =
lv
|> element("a[href='/rover-locations/#{loc.id}']", "View")
|> render_click()
|> follow_redirect(conn, ~p"/rover-locations/#{loc.id}")
assert html =~ "rooftop spot"
assert html =~ "EM12gm"
end
test "edit/delete buttons appear only on the owner's row", %{conn: conn, user: user} do test "edit/delete buttons appear only on the owner's row", %{conn: conn, user: user} do
other = Microwaveprop.AccountsFixtures.user_fixture() other = Microwaveprop.AccountsFixtures.user_fixture()
{:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal}) {:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})