prop/lib/microwaveprop_web/live/rover_planning_live/form.ex
Graham McIntire 1d4b2aeedb
fix(rover-planning): edit-mode remove_station deletes only the targeted row
Clicking the trash icon on a stationary station in :edit mode before
any other phx-change had fired emptied the form's stations params,
which combined with cast_assoc(on_replace: :delete) wiped EVERY
existing station instead of just the one the user clicked. Fall back
to rebuilding the params from the persisted mission's stations when
the form's current params don't already carry them, then delete only
the targeted index.
2026-05-03 14:11:38 -05:00

380 lines
12 KiB
Elixir

defmodule MicrowavepropWeb.RoverPlanningLive.Form do
@moduledoc "New / edit form for `/rover-planning` missions."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
@band_options [
{"902 MHz", 902},
{"1.3 GHz", 1296},
{"2.3 GHz", 2304},
{"3.4 GHz", 3400},
{"5.7 GHz", 5760},
{"10 GHz", 10_000},
{"24 GHz", 24_000},
{"47 GHz", 47_000}
]
@impl true
def mount(params, _session, socket) do
case current_user(socket) do
nil ->
{:ok,
socket
|> put_flash(:error, "Sign in to plan a mission.")
|> push_navigate(to: ~p"/rover-planning")}
%User{} = user ->
{:ok,
socket
|> assign(
page_title: "Rover Mission",
band_options: @band_options,
current_user: user
)
|> apply_action(socket.assigns.live_action, params)}
end
end
defp apply_action(socket, :new, _params) do
# Seed the initial changeset PARAMS (not just the struct) with one
# station row so add_station / remove_station have something to work
# with. Without this seed, cast_assoc treats the first add_station
# click as a replacement of the unseeded default — visually a no-op.
mission = %Mission{}
cs = Mission.changeset(mission, %{"stations" => %{"0" => %{"position" => "0"}}})
assign(socket,
mission: mission,
form: to_form(cs),
mode: :new
)
end
defp apply_action(socket, :edit, %{"id" => id}) do
user = socket.assigns.current_user
case RoverPlanning.get_mission(id) do
nil ->
socket
|> put_flash(:error, "Mission not found.")
|> push_navigate(to: ~p"/rover-planning")
mission ->
if can_modify?(user, mission) do
cs = Mission.changeset(mission, %{})
assign(socket,
mission: mission,
form: to_form(cs),
mode: :edit
)
else
socket
|> put_flash(:error, "You can only edit your own missions.")
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")
end
end
end
@impl true
def handle_event("validate", %{"mission" => params}, socket) do
cs =
socket.assigns.mission
|> Mission.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("add_station", _params, socket) do
params = current_params(socket)
stations = stations_params(socket, params)
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), %{
"position" => Integer.to_string(next_index)
})
cs =
socket.assigns.mission
|> Mission.changeset(Map.put(params, "stations", stations))
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("remove_station", %{"index" => index}, socket) do
params = current_params(socket)
stations = socket |> stations_params(params) |> Map.delete(index)
cs =
socket.assigns.mission
|> Mission.changeset(Map.put(params, "stations", stations))
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
# When the form changeset already carries a "stations" params map
# (every keystroke fires phx-change="validate" which builds one),
# trust it. Otherwise — e.g. the user clicked add/remove BEFORE
# touching any other input on an :edit-mode form — fall back to
# rebuilding the params from the persisted mission's stations so
# `cast_assoc(on_replace: :delete)` doesn't wipe every existing row.
defp stations_params(socket, %{"stations" => stations}) when is_map(stations) and stations != %{}, do: stations
defp stations_params(socket, _params), do: stations_from_mission(socket.assigns.mission)
defp stations_from_mission(%Mission{stations: list}) when is_list(list) do
list
|> Enum.with_index()
|> Map.new(fn {s, i} ->
{Integer.to_string(i),
%{
"id" => s.id,
"input" => s.input || s.callsign || s.grid,
"callsign" => s.callsign,
"grid" => s.grid,
"lat" => s.lat,
"lon" => s.lon,
"position" => Integer.to_string(s.position || i)
}}
end)
end
defp stations_from_mission(_), do: %{}
def handle_event("save", %{"mission" => params}, socket) do
user = socket.assigns.current_user
result =
case socket.assigns.mode do
:new -> RoverPlanning.create_mission(user, params)
:edit -> RoverPlanning.update_mission(user, socket.assigns.mission.id, params)
end
case result do
{:ok, mission} ->
{:noreply,
socket
|> put_flash(:info, "Mission saved — computing path profiles.")
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Mission not found.")}
{:error, %Ecto.Changeset{} = cs} ->
{:noreply, assign(socket, form: to_form(cs))}
end
end
defp current_params(socket) do
case socket.assigns.form.source do
%Ecto.Changeset{params: params} when is_map(params) -> params
_ -> %{}
end
end
defp next_station_index(stations) when map_size(stations) == 0, do: 0
defp next_station_index(stations) do
stations
|> Map.keys()
|> Enum.map(fn k ->
case Integer.parse(to_string(k)) do
{n, _} -> n
:error -> -1
end
end)
|> Enum.max()
|> Kernel.+(1)
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(%User{is_admin: true}, _), do: true
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
defp can_modify?(_, _), do: false
# Multi-checkbox values come from the form as a list of strings (or
# ints, depending on whether the changeset has cast them yet). Tolerate
# both, falling back to "not checked" for nil / empty bands lists.
defp band_checked?(values, target) when is_list(values) do
Enum.any?(values, fn
v when is_integer(v) -> v == target
v when is_binary(v) -> v == Integer.to_string(target)
_ -> false
end)
end
defp band_checked?(_, _), do: false
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
<.header>
{if @mode == :edit, do: "Edit mission", else: "New rover mission"}
<:subtitle>
Stationary stations can be callsigns, Maidenhead grids, or <code>lat, lon</code> pairs.
</:subtitle>
</.header>
<.form
for={@form}
id="mission-form"
phx-change="validate"
phx-submit="save"
class="space-y-4"
>
<.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" />
<div>
<label class="label">
<span class="label-text">Bands</span>
<span class="label-text-alt text-base-content/60">
Pick one or more — paths recompute per band
</span>
</label>
<%!--
Hidden zero-value field so an unchecked-everywhere submit
still parses as an empty list (Phoenix drops the key
otherwise). Mission.changeset rejects an empty bands_mhz
with a friendly error.
--%>
<input type="hidden" name="mission[bands_mhz][]" value="" />
<div class="flex flex-wrap gap-2">
<label
:for={{label, value} <- @band_options}
class="cursor-pointer flex items-center gap-2 border border-base-300 rounded px-3 py-1 text-sm"
>
<input
type="checkbox"
name="mission[bands_mhz][]"
value={value}
checked={band_checked?(@form[:bands_mhz].value, value)}
class="checkbox checkbox-sm checkbox-primary"
/>
<span>{label}</span>
</label>
</div>
<p :if={@form[:bands_mhz].errors != []} class="text-error text-sm mt-1">
{Phoenix.HTML.html_escape(Enum.map_join(@form[:bands_mhz].errors, ", ", &elem(&1, 0)))}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.input
field={@form[:rover_height_ft]}
type="number"
step="any"
label="Rover antenna (ft)"
/>
<.input
field={@form[:station_height_ft]}
type="number"
step="any"
label="Station antenna (ft)"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="hidden" name="mission[only_known_good]" value="false" />
<input
type="checkbox"
name="mission[only_known_good]"
value="true"
checked={Phoenix.HTML.Form.normalize_value("checkbox", @form[:only_known_good].value)}
class="checkbox checkbox-primary"
/>
<span>
Only check against known good locations
<span class="text-xs opacity-70 block">
When checked, paths are computed only against rover-locations marked Good.
</span>
</span>
</label>
<div>
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Stationary stations</h3>
<button
type="button"
phx-click="add_station"
class="btn btn-xs btn-ghost"
>
<.icon name="hero-plus" class="w-4 h-4" /> Add station
</button>
</div>
<.inputs_for :let={s} field={@form[:stations]}>
<div class="card bg-base-200 p-3 mb-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-start gap-2">
<div class="flex-1">
<.input field={s[:lat]} type="number" step="any" label="Lat" />
</div>
<div class="flex-1">
<.input field={s[:lon]} type="number" step="any" label="Lon" />
</div>
<button
type="button"
phx-click="remove_station"
phx-value-index={s.index}
class="btn btn-ghost btn-sm text-error mt-6"
title="Remove station"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</div>
<p :if={s[:callsign].value} class="text-xs opacity-70 mt-1 px-1">
Resolved callsign: {s[:callsign].value}
<span :if={s[:grid].value}>· grid {s[:grid].value}</span>
</p>
</div>
</.inputs_for>
<p :if={@form[:stations].errors != []} class="text-error text-sm">
{Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}
</p>
</div>
<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
<div class="flex gap-2">
<button type="submit" class="btn btn-primary">
{if @mode == :edit, do: "Save changes", else: "Create mission"}
</button>
<.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel</.link>
</div>
</.form>
</Layouts.app>
"""
end
end