prop/lib/microwaveprop_web/live/rover_planning_live/form.ex
Graham McIntire eca463add1
fix(rover-planning): keep only_known_good checked across phx-change events
The hidden 'false' shadow input was rendered after the checkbox, so when
both were submitted the 'false' value overwrote the checkbox 'true' in
the resulting params map. Move the hidden input before the checkbox so
the checked value wins (matching the core_components checkbox pattern).
2026-05-03 11:19:14 -05:00

299 lines
8.8 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
alias Microwaveprop.RoverPlanning.Station
@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
mission = %Mission{stations: [%Station{position: 0}]}
cs = Mission.changeset(mission, %{})
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 = Map.get(params, "stations", %{})
next_index = next_station_index(stations)
stations =
Map.put(stations, Integer.to_string(next_index), %{
"input" => "",
"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 = Map.get(params, "stations", %{})
stations = Map.delete(stations, 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("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
@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 class="grid grid-cols-1 md:grid-cols-3 gap-3">
<.input
field={@form[:band_mhz]}
type="select"
label="Band"
options={@band_options}
/>
<.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">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<.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-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 mb-2"
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