feat(rover): two-way grid <-> lat/lon sync on location form

Adds a Maidenhead grid input to the rover-location form. Typing a grid
fills in lat/lon (using square center); editing lat/lon recomputes the
grid at 6-char precision. The phx-change `_target` decides direction so
edits never clobber what the user just typed.
This commit is contained in:
Graham McIntire 2026-05-02 16:50:18 -05:00
parent 7eafa4c71a
commit 4a82571cda
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 106 additions and 12 deletions

View file

@ -56,6 +56,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
page_title: "Rover Locations",
form: nil,
editing_id: nil,
grid_input: "",
status_filter: nil
)
|> assign(:data_provider, {__MODULE__, :visible_query_provider, [nil]})}
@ -75,7 +76,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
def handle_event("new", _params, socket) do
if user = current_user(socket) do
{:noreply, assign(socket, form: blank_form(user), editing_id: nil)}
{:noreply, assign(socket, form: blank_form(user), editing_id: nil, grid_input: "")}
else
{:noreply, put_flash(socket, :error, "Sign in to add a location.")}
end
@ -87,7 +88,8 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
case Repo.get(Location, id) do
%Location{user_id: ^user_id} = loc ->
cs = Location.changeset(loc, %{})
{:noreply, assign(socket, form: to_form(cs), editing_id: id)}
grid = Maidenhead.from_latlon(loc.lat, loc.lon, 6)
{:noreply, assign(socket, form: to_form(cs), editing_id: id, grid_input: grid)}
_ ->
{:noreply, put_flash(socket, :error, "You can only edit your own locations.")}
@ -99,13 +101,16 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
end
def handle_event("cancel", _, socket) do
{:noreply, assign(socket, form: nil, editing_id: nil)}
{:noreply, assign(socket, form: nil, editing_id: nil, grid_input: "")}
end
def handle_event("validate", %{"location" => params}, socket) do
def handle_event("validate", %{"location" => params} = event_params, socket) do
target = event_params["_target"]
{params, grid_input} = sync_grid_and_latlon(params, target, socket.assigns.grid_input)
base = if socket.assigns.editing_id, do: location_for_edit(socket), else: %Location{}
cs = base |> Location.changeset(params) |> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
{:noreply, assign(socket, form: to_form(cs), grid_input: grid_input)}
end
def handle_event("save", %{"location" => params}, socket) do
@ -201,6 +206,44 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
defp parse_status(value) when value in @valid_status_filters, do: value
defp parse_status(_), do: nil
# Two-way grid <-> lat/lon sync. `_target` from the phx-change event
# tells us which field the user actually edited; we mirror the change
# into the other side. Unparseable input leaves the other side alone.
defp sync_grid_and_latlon(params, ["location", "grid"], _old_grid) do
raw = params |> Map.get("grid", "") |> to_string() |> String.trim()
case Maidenhead.to_latlon(raw) do
{:ok, {lat, lon}} ->
params =
params
|> Map.put("lat", format_coord(lat))
|> Map.put("lon", format_coord(lon))
{params, raw}
:error ->
{params, raw}
end
end
defp sync_grid_and_latlon(params, ["location", field], old_grid) when field in ["lat", "lon"] do
with {lat, ""} <- params |> Map.get("lat", "") |> to_string() |> String.trim() |> Float.parse(),
{lon, ""} <- params |> Map.get("lon", "") |> to_string() |> String.trim() |> Float.parse(),
lat when lat >= -90.0 and lat <= 90.0 <- lat,
lon when lon >= -180.0 and lon <= 180.0 <- lon do
grid = Maidenhead.from_latlon(lat, lon, 6)
{Map.put(params, "grid", grid), grid}
else
_ -> {params, old_grid}
end
end
defp sync_grid_and_latlon(params, _target, old_grid), do: {params, old_grid}
defp format_coord(value) do
value |> Float.round(6) |> Float.to_string()
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
@ -325,7 +368,22 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
{if @editing_id, do: "Edit location", else: "New location"}
</h2>
<.form for={@form} phx-change="validate" phx-submit="save" id="location-form">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.input
name="location[grid]"
value={@grid_input}
type="text"
label="Grid (Maidenhead)"
placeholder="EM12kp"
/>
<.input
field={@form[:status]}
type="select"
label="Status"
options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]}
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.input
field={@form[:lat]}
type="number"
@ -340,12 +398,6 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
label="Longitude"
placeholder="-97.5"
/>
<.input
field={@form[:status]}
type="select"
label="Status"
options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]}
/>
</div>
<.input
field={@form[:notes]}

View file

@ -79,6 +79,48 @@ defmodule MicrowavepropWeb.RoverLocationsLiveTest do
assert html =~ "test note"
end
test "typing a grid populates lat/lon", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/rover-locations")
lv |> element("button", "Add location") |> render_click()
html =
lv
|> form("#location-form",
location: %{
grid: "EM12kp",
lat: "",
lon: "",
status: "ideal",
notes: ""
}
)
|> render_change(%{"_target" => ["location", "grid"]})
# EM12kp center: lat 32.645833, lon -97.125
assert html =~ ~s(value="32.645833")
assert html =~ ~s(value="-97.125")
end
test "typing lat/lon populates the grid field", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/rover-locations")
lv |> element("button", "Add location") |> render_click()
html =
lv
|> form("#location-form",
location: %{
grid: "",
lat: "32.5",
lon: "-97.5",
status: "ideal",
notes: ""
}
)
|> render_change(%{"_target" => ["location", "lat"]})
assert html =~ ~s(value="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})