From d44351366aa082272d7b496dbf374d4b0389bb62 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 2 May 2026 17:02:00 -0500 Subject: [PATCH] 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. --- .../live/rover_locations_live.ex | 18 +- .../live/rover_locations_live/show.ex | 160 ++++++++++++++++++ lib/microwaveprop_web/router.ex | 1 + .../live/rover_locations_live/show_test.exs | 68 ++++++++ .../live/rover_locations_live_test.exs | 21 +++ 5 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 lib/microwaveprop_web/live/rover_locations_live/show.ex create mode 100644 test/microwaveprop_web/live/rover_locations_live/show_test.exs diff --git a/lib/microwaveprop_web/live/rover_locations_live.ex b/lib/microwaveprop_web/live/rover_locations_live.ex index 828d4fb2..a7b3dbfa 100644 --- a/lib/microwaveprop_web/live/rover_locations_live.ex +++ b/lib/microwaveprop_web/live/rover_locations_live.ex @@ -277,8 +277,15 @@ defmodule MicrowavepropWeb.RoverLocationsLive do assigns = %{record: record, owner?: owner?(scope, record)} ~H""" -
+
+ <.link + navigate={~p"/rover-locations/#{@record.id}"} + class="btn btn-xs btn-ghost" + > + View +
+ """ end diff --git a/lib/microwaveprop_web/live/rover_locations_live/show.ex b/lib/microwaveprop_web/live/rover_locations_live/show.ex new file mode 100644 index 00000000..f8c24c9c --- /dev/null +++ b/lib/microwaveprop_web/live/rover_locations_live/show.ex @@ -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""" + + <.header> + Rover Location + <:subtitle> + {status_label(@location.status)} + {@grid} + + <:actions> + <.link navigate={~p"/rover-locations"} class="btn btn-ghost btn-sm"> + <.icon name="hero-arrow-left" class="w-4 h-4" /> Back + + + + + +
+
+
+
Grid
+
{@grid}
+
+
+
Coordinates
+
+ {Float.round(@location.lat, 6)}, {Float.round(@location.lon, 6)} +
+
+
+
Status
+
+ + {status_label(@location.status)} + +
+
+
+
Added
+
{Calendar.strftime(@location.inserted_at, "%Y-%m-%d %H:%M UTC")}
+
+
+
Submitted by
+
+ <.link + navigate={~p"/u/#{@location.user.callsign}"} + class="link link-primary font-mono" + > + {@location.user.callsign} + +
+
+
+ +
+

Notes

+

{@location.notes}

+
+
+ +
+
+
+ """ + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 2a9a1f81..79eaaead 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -197,6 +197,7 @@ defmodule MicrowavepropWeb.Router do live "/skewt", SkewtLive live "/rover", RoverLive live "/rover-locations", RoverLocationsLive + live "/rover-locations/:id", RoverLocationsLive.Show live "/algo", AlgoLive live "/about", AboutLive live "/privacy", PrivacyLive diff --git a/test/microwaveprop_web/live/rover_locations_live/show_test.exs b/test/microwaveprop_web/live/rover_locations_live/show_test.exs new file mode 100644 index 00000000..fdf9e9fe --- /dev/null +++ b/test/microwaveprop_web/live/rover_locations_live/show_test.exs @@ -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 diff --git a/test/microwaveprop_web/live/rover_locations_live_test.exs b/test/microwaveprop_web/live/rover_locations_live_test.exs index b2ed7f68..1e363eda 100644 --- a/test/microwaveprop_web/live/rover_locations_live_test.exs +++ b/test/microwaveprop_web/live/rover_locations_live_test.exs @@ -121,6 +121,27 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do assert html =~ ~s(value="EM12gm") 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 other = Microwaveprop.AccountsFixtures.user_fixture() {:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})