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:
parent
4a82571cda
commit
d44351366a
5 changed files with 263 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
160
lib/microwaveprop_web/live/rover_locations_live/show.ex
Normal file
160
lib/microwaveprop_web/live/rover_locations_live/show.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue