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)}
|
||||
|
||||
~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
|
||||
:if={@owner?}
|
||||
type="button"
|
||||
phx-click="edit"
|
||||
phx-value-id={@record.id}
|
||||
|
|
@ -287,6 +294,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
|
|||
Edit
|
||||
</button>
|
||||
<button
|
||||
:if={@owner?}
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={@record.id}
|
||||
|
|
@ -311,17 +319,17 @@ defmodule MicrowavepropWeb.RoverLocationsLive 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)
|
||||
assigns = %{lat: lat, lon: lon, grid: grid}
|
||||
assigns = %{id: id, lat: lat, lon: lon, grid: grid}
|
||||
|
||||
~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-xs text-base-content/60">
|
||||
{Float.round(@lat, 5)}, {Float.round(@lon, 5)}
|
||||
</span>
|
||||
</div>
|
||||
</.link>
|
||||
"""
|
||||
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 "/rover", RoverLive
|
||||
live "/rover-locations", RoverLocationsLive
|
||||
live "/rover-locations/:id", RoverLocationsLive.Show
|
||||
live "/algo", AlgoLive
|
||||
live "/about", AboutLive
|
||||
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")
|
||||
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})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue