prop/lib/microwaveprop_web/live/rover_locations_live/show.ex
Graham McIntire 08ac76a4b8
refactor(rover): pattern-match-first event handlers + missing test branches
show.ex (rover-planning) and show.ex (rover-locations) auth + delete
flows go from nested case/case/case to a `with` ladder fed by a single
`authenticated/1` clause that returns `{:ok, user}` or
`{:error, :unauthenticated}`. The else block enumerates the small set
of failure tuples instead of rebuilding nested error returns.

Other idiomatic-Elixir tightening:
- `progress_summary/1` reduces with a `tally_path/2` multi-clause
  helper (status as a head pattern, not three Enum.count passes).
- `paths_by_rover_location/1` extracts `group_to_pair/1`,
  `station_position/1`, and `group_lat/1` so each transformation is a
  pattern match instead of an anonymous fn with `&& fallbacks`.
- `error_summary/1` is two clauses (empty vs populated errors)
  instead of a pipe-into-case.
- Drag-to-edit show.ex consolidates the working-coords assigns into
  `assign_working_coords/3`.

Bug fix uncovered while writing tests: `add_station` on a fresh
/rover-planning/new form was a visual no-op on the first click —
cast_assoc replaced the unseeded default Station struct with the new
params row instead of appending. Now the initial changeset is built
from `%{"stations" => %{"0" => %{"position" => "0"}}}` so add_station
appends from the get-go. Regression test added at
test/microwaveprop_web/live/rover_planning_live_test.exs.

New test branches:
- form: add_station appends on first click + remove_station drops
  the targeted row.
- rover-planning show: add_rover_site whitespace-input rejection,
  delete_rover_site permission denial.
- rover-locations show: save_edit rejects unauthenticated drivers.

Suite: 3228 tests, 0 failures. Credo strict: 0 issues.
2026-05-03 13:01:36 -05:00

238 lines
7.8 KiB
Elixir

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,
editing: false,
working_lat: loc.lat,
working_lon: loc.lon,
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("toggle_edit", _params, socket) do
if can_modify?(socket.assigns[:current_scope], socket.assigns.location) do
{:noreply,
socket
|> assign(editing: true)
|> push_event("set_marker_draggable", %{draggable: true})}
else
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
end
end
def handle_event("cancel_edit", _params, socket) do
%Location{lat: lat, lon: lon} = socket.assigns.location
{:noreply,
socket
|> assign_working_coords(lat, lon)
|> assign(editing: false)
|> push_event("reset_marker", %{lat: lat, lon: lon})
|> push_event("set_marker_draggable", %{draggable: false})}
end
# Hook fires this on `dragend` with the marker's new coordinates.
# We only update the working preview — persistence waits for save.
def handle_event("location_dragged", %{"lat" => lat, "lon" => lon}, socket) when is_number(lat) and is_number(lon) do
{:noreply, assign_working_coords(socket, lat, lon)}
end
def handle_event("save_edit", _params, socket) do
%{working_lat: lat, working_lon: lon, location: %Location{id: id}} = socket.assigns
with {:ok, user} <- authenticated(socket),
{:ok, updated} <- Rover.update_location(user, id, %{lat: lat, lon: lon}) do
updated = Repo.preload(updated, :user)
{:noreply,
socket
|> assign(location: updated, editing: false)
|> assign_working_coords(updated.lat, updated.lon)
|> put_flash(:info, "Location updated.")
|> push_event("set_marker_draggable", %{draggable: false})}
else
{:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in required.")}
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
{:error, %Ecto.Changeset{}} -> {:noreply, put_flash(socket, :error, "Could not save those coordinates.")}
end
end
def handle_event("delete", _params, socket) do
with {:ok, user} <- authenticated(socket),
{:ok, _} <- Rover.delete_location(user, socket.assigns.location.id) do
{:noreply,
socket
|> put_flash(:info, "Location removed.")
|> push_navigate(to: ~p"/rover-locations")}
else
{:error, :unauthenticated} -> {:noreply, put_flash(socket, :error, "Sign in required.")}
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "You can only delete your own locations.")}
end
end
defp assign_working_coords(socket, lat, lon) do
assign(socket,
working_lat: lat,
working_lon: lon,
grid: Maidenhead.from_latlon(lat, lon, 10)
)
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 authenticated(%Phoenix.LiveView.Socket{assigns: %{current_scope: %{user: %User{} = user}}}), do: {:ok, user}
defp authenticated(_), do: {:error, :unauthenticated}
defp can_modify?(%{user: %User{is_admin: true}}, _), do: true
defp can_modify?(%{user: %User{id: id}}, %Location{user_id: id}) when not is_nil(id), do: true
defp can_modify?(_, _), do: false
defp status_label(:good), do: "Good"
defp status_label(:bad), do: "Bad"
defp status_label(_), do: ""
defp status_class(:good), do: "badge badge-success"
defp status_class(:bad), 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}
<span :if={@editing} class="ml-2 badge badge-warning">Editing — drag the marker</span>
</: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={can_modify?(@current_scope, @location) and not @editing}
type="button"
phx-click="toggle_edit"
class="btn btn-ghost btn-sm"
>
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
</button>
<button
:if={@editing}
type="button"
phx-click="save_edit"
class="btn btn-primary btn-sm"
>
<.icon name="hero-check" class="w-4 h-4" /> Save
</button>
<button
:if={@editing}
type="button"
phx-click="cancel_edit"
class="btn btn-ghost btn-sm"
>
Cancel
</button>
<button
:if={can_modify?(@current_scope, @location) and not @editing}
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(@working_lat, 6)}, {Float.round(@working_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