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 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 # 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: %{} 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""" <.header> {if @mode == :edit, do: "Edit mission", else: "New rover mission"} <:subtitle> Stationary stations can be callsigns, Maidenhead grids, or lat, lon pairs. <.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" />
<%!-- 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. --%>

{Phoenix.HTML.html_escape(Enum.map_join(@form[:bands_mhz].errors, ", ", &elem(&1, 0)))}

<.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)" />

Stationary stations

<.inputs_for :let={s} field={@form[:stations]}>
<%!-- `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. --%>
<.input field={s[:input]} type="text" label="Callsign / grid / lat,lon" placeholder="W5LUA or EM13qc or 32.91, -97.06" phx-debounce="600" />
<.input field={s[:lat]} type="number" step="any" label="Lat" />
<.input field={s[:lon]} type="number" step="any" label="Lon" />

Resolved callsign: {s[:callsign].value} · grid {s[:grid].value}

{Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}

<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
<.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel
""" end end