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.
380 lines
12 KiB
Elixir
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
|