prop/lib/microwaveprop_web/live/rover_planning_live/form.ex
Graham McIntire 209244f364
refactor(rover-locations): rename status atoms ideal→good, off_limits→bad
Renames the rover-location status enum and every label that referenced
it. Existing rows are migrated in place. Also touches the consumers:

- Rover.Location enum + default
- RoverLocationsLive index, status filter, form, show, badges
- Rover.Compute and rover_live (only_ideal_locations → only_good_locations)
- RoverPlanning context candidate filter (status == :good)
- RoverPlanning.Show / Form copy
- All rover-related tests
2026-05-03 11:13:18 -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="checkbox"
name="mission[only_known_good]"
value="true"
checked={Phoenix.HTML.Form.normalize_value("checkbox", @form[:only_known_good].value)}
class="checkbox checkbox-primary"
/>
<input type="hidden" name="mission[only_known_good]" value="false" />
<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