fix(rover-planning): suppress fresh-station error + tolerate integer JSONB values

- add_station no longer seeds `"input" => ""` in params, so used_input?/1
  treats the new row as untouched and hides the validate_required error
  until the user types.
- Show page's format_distance/format_db/format_meters helpers coerce with
  `* 1.0` because JSONB returns bare zeros as integers, which crashed
  Float.round/2 (FunctionClauseError on /rover-planning/:id render).
- Lat/lon row uses items-start + mt-6 trash so the inputs stay aligned
  even when the callsign cell grows from validation feedback.
This commit is contained in:
Graham McIntire 2026-05-03 11:29:57 -05:00
parent eca463add1
commit cff0c6775d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 67 additions and 9 deletions

View file

@ -91,9 +91,12 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
stations = Map.get(params, "stations", %{}) stations = Map.get(params, "stations", %{})
next_index = next_station_index(stations) next_index = next_station_index(stations)
# Don't include "input" in the new station's params — `used_input?/1`
# treats any field present in params as "used" and would render the
# `validate_required([:input])` error before the user has typed
# anything. Leaving the key out keeps the row pristine until edited.
stations = stations =
Map.put(stations, Integer.to_string(next_index), %{ Map.put(stations, Integer.to_string(next_index), %{
"input" => "",
"position" => Integer.to_string(next_index) "position" => Integer.to_string(next_index)
}) })
@ -247,14 +250,20 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
<.inputs_for :let={s} field={@form[:stations]}> <.inputs_for :let={s} field={@form[:stations]}>
<div class="card bg-base-200 p-3 mb-2"> <div class="card bg-base-200 p-3 mb-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <%!--
`items-start` keeps the lat/lon row top-aligned even when the
callsign cell grows (e.g. an error message under the input).
The trash button uses `mt-6` to sit beside the input matches
the label height so it visually aligns with the input field.
--%>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 items-start">
<.input <.input
field={s[:input]} field={s[:input]}
type="text" type="text"
label="Callsign / grid / lat,lon" label="Callsign / grid / lat,lon"
placeholder="W5LUA or EM13qc or 32.91, -97.06" placeholder="W5LUA or EM13qc or 32.91, -97.06"
/> />
<div class="flex items-end gap-2"> <div class="flex items-start gap-2">
<div class="flex-1"> <div class="flex-1">
<.input field={s[:lat]} type="number" step="any" label="Lat" /> <.input field={s[:lat]} type="number" step="any" label="Lat" />
</div> </div>
@ -265,7 +274,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
type="button" type="button"
phx-click="remove_station" phx-click="remove_station"
phx-value-index={s.index} phx-value-index={s.index}
class="btn btn-ghost btn-sm text-error mb-2" class="btn btn-ghost btn-sm text-error mt-6"
title="Remove station" title="Remove station"
> >
<.icon name="hero-trash" class="w-4 h-4" /> <.icon name="hero-trash" class="w-4 h-4" />

View file

@ -124,12 +124,18 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
%{total: total, complete: complete, pending: pending, failed: failed} %{total: total, complete: complete, pending: pending, failed: failed}
end end
defp format_distance(km) when is_number(km), do: "#{Float.round(km, 1)} km / #{Float.round(km * 0.621371, 1)} mi" # JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), and
# `Float.round/2` only accepts floats. Coerce with `* 1.0` everywhere
# we round a value that came out of a `:map` column.
defp format_distance(km) when is_number(km), do: "#{Float.round(km * 1.0, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
defp format_distance(_), do: "" defp format_distance(_), do: ""
defp format_db(nil), do: "" defp format_db(nil), do: ""
defp format_db(value) when is_number(value), do: "#{Float.round(value, 1)} dB" defp format_db(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} dB"
defp format_meters(nil), do: ""
defp format_meters(value) when is_number(value), do: "#{Float.round(value * 1.0, 1)} m"
defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do
grid = Maidenhead.from_latlon(lat, lon, 6) grid = Maidenhead.from_latlon(lat, lon, 6)
@ -243,9 +249,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
{if path.result, do: format_distance(path.result["distance_km"]), else: ""} {if path.result, do: format_distance(path.result["distance_km"]), else: ""}
</td> </td>
<td class="text-xs"> <td class="text-xs">
{if path.result, {if path.result, do: format_meters(path.result["min_clearance_m"]), else: ""}
do: "#{Float.round(path.result["min_clearance_m"] || 0.0, 1)} m",
else: ""}
</td> </td>
<td class="text-xs"> <td class="text-xs">
{if path.result, do: format_db(path.result["diffraction_db"]), else: ""} {if path.result, do: format_db(path.result["diffraction_db"]), else: ""}

View file

@ -4,8 +4,10 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Microwaveprop.AccountsFixtures alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Rover alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
setup do setup do
@ -69,6 +71,34 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
assert html =~ "Path profiles" assert html =~ "Path profiles"
end end
test "renders integer path-result values without crashing", %{conn: conn} do
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), so the
# show page MUST tolerate non-float numerics in `result`. Previously
# `Float.round(value, n)` crashed with FunctionClauseError when value
# came back as integer 0.
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Integer result mission")
[path | _] = Repo.all(Path)
{:ok, _} =
path
|> Ecto.Changeset.change(%{
status: :complete,
result: %{
"distance_km" => 0,
"min_clearance_m" => 0,
"diffraction_db" => 0,
"verdict" => "clear"
}
})
|> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
assert html =~ "Integer result mission"
assert html =~ "Clear"
end
test "redirects when mission missing", %{conn: conn} do test "redirects when mission missing", %{conn: conn} do
missing = Ecto.UUID.generate() missing = Ecto.UUID.generate()
@ -83,6 +113,21 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
live(conn, ~p"/rover-planning/new") live(conn, ~p"/rover-planning/new")
end end
test "clicking 'Add station' does not show 'can't be blank' on the new row", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-planning/new")
html =
lv
|> element("button[phx-click='add_station']")
|> render_click()
# The new (untouched) station must not render a validation error until
# the user actually interacts with its input.
refute html =~ "can&#39;t be blank"
end
test "typing into the form does not flip the only_known_good checkbox", %{conn: conn} do test "typing into the form does not flip the only_known_good checkbox", %{conn: conn} do
user = AccountsFixtures.user_fixture() user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user) conn = log_in_user(conn, user)