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", %{})
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 =
Map.put(stations, Integer.to_string(next_index), %{
"input" => "",
"position" => Integer.to_string(next_index)
})
@ -247,14 +250,20 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
<.inputs_for :let={s} field={@form[:stations]}>
<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
field={s[:input]}
type="text"
label="Callsign / grid / lat,lon"
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">
<.input field={s[:lat]} type="number" step="any" label="Lat" />
</div>
@ -265,7 +274,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
type="button"
phx-click="remove_station"
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"
>
<.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}
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_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
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: ""}
</td>
<td class="text-xs">
{if path.result,
do: "#{Float.round(path.result["min_clearance_m"] || 0.0, 1)} m",
else: ""}
{if path.result, do: format_meters(path.result["min_clearance_m"]), else: ""}
</td>
<td class="text-xs">
{if path.result, do: format_db(path.result["diffraction_db"]), else: ""}

View file

@ -4,8 +4,10 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
import Phoenix.LiveViewTest
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.Terrain.ElevationClient
setup do
@ -69,6 +71,34 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
assert html =~ "Path profiles"
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
missing = Ecto.UUID.generate()
@ -83,6 +113,21 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
live(conn, ~p"/rover-planning/new")
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
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)